keya: initial commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>tetris</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "static"
|
||||
}
|
||||
Generated
+1294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "tetris",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
+105
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react'
|
||||
import Board from './components/Board'
|
||||
import Controls from './components/Controls'
|
||||
import Overlay from './components/Overlay'
|
||||
import Preview from './components/Preview'
|
||||
import Stats from './components/Stats'
|
||||
import { useTetris } from './hooks/useTetris'
|
||||
|
||||
const CELL_SIZE = 30
|
||||
|
||||
export default function App() {
|
||||
const { state, actions, ghost } = useTetris()
|
||||
const { status, current, board, queue, score, level, lines } = state
|
||||
const running = status === 'playing'
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e) => {
|
||||
const key = e.key
|
||||
if (key === ' ' || key === 'ArrowDown' || key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
}
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
actions.left()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
actions.right()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
actions.soft()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
case 'x':
|
||||
case 'X':
|
||||
actions.rotate()
|
||||
break
|
||||
case ' ':
|
||||
actions.hard()
|
||||
break
|
||||
case 'p':
|
||||
case 'P':
|
||||
case 'Escape':
|
||||
actions.pause()
|
||||
break
|
||||
case 'Enter':
|
||||
if (status === 'idle') actions.start()
|
||||
else if (status === 'over') actions.restart()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [actions, status])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Tetris</h1>
|
||||
<p className="app-subtitle">React ile klasik blok bulmacası</p>
|
||||
</header>
|
||||
<main className="layout">
|
||||
<section className="panel">
|
||||
<Stats label="Panel" score={score} lines={lines} level={level} status={status} />
|
||||
<div className="board-wrap">
|
||||
<Board board={board} current={current} ghost={ghost} cellSize={CELL_SIZE} />
|
||||
<Overlay
|
||||
status={status}
|
||||
onStart={actions.start}
|
||||
actions={actions}
|
||||
score={score}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="side">
|
||||
<div className="side-card">
|
||||
<h3>Sıradaki</h3>
|
||||
<Preview queue={queue} count={3} />
|
||||
</div>
|
||||
<div className="side-card side-actions">
|
||||
<button className="ghost-button" onClick={actions.pause} disabled={!running && status !== 'paused'}>
|
||||
{status === 'paused' ? 'Devam' : 'Duraklat'}
|
||||
</button>
|
||||
<button className="ghost-button" onClick={actions.restart}>
|
||||
Yeniden Başlat
|
||||
</button>
|
||||
</div>
|
||||
<div className="side-card keys-card">
|
||||
<h3>Kontroller</h3>
|
||||
<ul className="keys">
|
||||
<li><kbd>←</kbd><kbd>→</kbd> hareket</li>
|
||||
<li><kbd>↓</kbd> yavaş bırak</li>
|
||||
<li><kbd>↑</kbd> / <kbd>X</kbd> döndür</li>
|
||||
<li><kbd>Space</kbd> hızlı bırak</li>
|
||||
<li><kbd>P</kbd> / <kbd>Esc</kbd> duraklat</li>
|
||||
<li><kbd>Enter</kbd> başlat / yeniden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
<Controls actions={actions} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,55 @@
|
||||
import { COLS, COLOR, ROWS, currentCells } from '../game/tetris'
|
||||
|
||||
export default function Board({ board, current, ghost, cellSize }) {
|
||||
const cells = []
|
||||
const pieceCells = new Map()
|
||||
const ghostSet = new Set()
|
||||
|
||||
if (current) {
|
||||
currentCells(current).forEach(([x, y]) => pieceCells.set(`${x},${y}`, current.type))
|
||||
}
|
||||
if (ghost) {
|
||||
ghost.forEach(([x, y]) => {
|
||||
if (x >= 0 && x < COLS && y >= 0 && y < ROWS && !pieceCells.has(`${x},${y}`)) {
|
||||
ghostSet.add(`${x},${y}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const key = `${x},${y}`
|
||||
const type = pieceCells.get(key) ?? board[y][x]
|
||||
const isGhost = ghostSet.has(key)
|
||||
cells.push(
|
||||
<div
|
||||
key={key}
|
||||
className={isGhost ? 'cell ghost' : type ? 'cell filled' : 'cell'}
|
||||
style={{
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
background: isGhost
|
||||
? `rgba(255,255,255,0.08)`
|
||||
: type
|
||||
? COLOR[type]
|
||||
: undefined,
|
||||
boxShadow: type && !isGhost ? `inset 0 0 0 1px rgba(0,0,0,0.25)` : undefined,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${COLS}, ${cellSize}px)`,
|
||||
gridTemplateRows: `repeat(${ROWS}, ${cellSize}px)`,
|
||||
}}
|
||||
>
|
||||
{cells}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export default function Controls({ actions }) {
|
||||
return (
|
||||
<div className="controls">
|
||||
<div className="control-row">
|
||||
<button onClick={actions.left} aria-label="Sola">
|
||||
◀
|
||||
</button>
|
||||
<button onClick={actions.rotate} aria-label="Döndür">
|
||||
⟳
|
||||
</button>
|
||||
<button onClick={actions.right} aria-label="Sağa">
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
<div className="control-row">
|
||||
<button onClick={actions.soft} aria-label="Yavaş bırak">
|
||||
▼
|
||||
</button>
|
||||
<button onClick={actions.hard} aria-label="Hızlı bırak">
|
||||
⏬
|
||||
</button>
|
||||
<button onClick={actions.pause} aria-label="Duraklat">
|
||||
⏸
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export default function Overlay({ status, onStart, actions, score }) {
|
||||
if (status === 'playing') return null
|
||||
|
||||
let title
|
||||
let subtitle
|
||||
let buttonLabel
|
||||
let onButton
|
||||
|
||||
if (status === 'idle') {
|
||||
title = 'Tetris'
|
||||
subtitle = 'Puzzle populemizi toplayın'
|
||||
buttonLabel = 'Başlat'
|
||||
onButton = onStart
|
||||
} else if (status === 'paused') {
|
||||
title = 'Duraklatıldı'
|
||||
subtitle = 'Devam etmek için tekrar başlatın'
|
||||
buttonLabel = 'Devam Et'
|
||||
onButton = actions.pause
|
||||
} else {
|
||||
title = 'Oyun Bitti'
|
||||
subtitle = `Skorunuz: ${score}`
|
||||
buttonLabel = 'Yeniden Oyna'
|
||||
onButton = actions.restart
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<div className="overlay-card">
|
||||
<h2 className="overlay-title">{title}</h2>
|
||||
{subtitle && <p className="overlay-subtitle">{subtitle}</p>}
|
||||
<button className="overlay-button" onClick={onButton ?? onStart}>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { COLOR, rotationCells } from '../game/tetris'
|
||||
|
||||
export default function Preview({ queue, count }) {
|
||||
const previews = queue.slice(0, count ?? 3)
|
||||
return (
|
||||
<div className="preview-list">
|
||||
{previews.map((type, index) => (
|
||||
<MiniPiece key={index} type={type} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniPiece({ type }) {
|
||||
const cells = rotationCells(type, 0)
|
||||
const minX = Math.min(...cells.map(([x]) => x))
|
||||
const minY = Math.min(...cells.map(([, y]) => y))
|
||||
const maxX = Math.max(...cells.map(([x]) => x))
|
||||
const maxY = Math.max(...cells.map(([, y]) => y))
|
||||
const width = maxX - minX + 1
|
||||
const height = maxY - minY + 1
|
||||
const size = 22
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mini"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${width}, ${size}px)`,
|
||||
gridTemplateRows: `repeat(${height}, ${size}px)`,
|
||||
width: width * size,
|
||||
height: height * size,
|
||||
}}
|
||||
>
|
||||
{cells.map(([x, y], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mini-mask"
|
||||
style={{
|
||||
gridColumn: x - minX + 1,
|
||||
gridRow: y - minY + 1,
|
||||
background: COLOR[type],
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export default function Stats({ score, lines, level, status }) {
|
||||
return (
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Skor</span>
|
||||
<span className="stat-value">{score}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Seviye</span>
|
||||
<span className="stat-value">{level}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Satır</span>
|
||||
<span className="stat-value">{lines}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Durum</span>
|
||||
<span className="stat-value status-text">
|
||||
{status === 'idle' ? '—' : status === 'playing' ? 'Oyun' : status === 'paused' ? 'Duraklatıldı' : 'Bitti'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
export const COLS = 10
|
||||
export const ROWS = 20
|
||||
|
||||
// Pieces in local coordinates (normalized so min x/y = 0).
|
||||
const SHAPES = {
|
||||
I: [[0, 0], [1, 0], [2, 0], [3, 0]],
|
||||
O: [[0, 0], [1, 0], [0, 1], [1, 1]],
|
||||
T: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
||||
S: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
||||
Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
||||
J: [[0, 0], [0, 1], [1, 1], [2, 1]],
|
||||
L: [[2, 0], [0, 1], [1, 1], [2, 1]],
|
||||
}
|
||||
|
||||
export const COLOR = {
|
||||
I: '#22d3ee',
|
||||
O: '#facc15',
|
||||
T: '#c084fc',
|
||||
S: '#4ade80',
|
||||
Z: '#f87171',
|
||||
J: '#60a5fa',
|
||||
L: '#fb923c',
|
||||
}
|
||||
|
||||
export const PIECE_TYPES = Object.keys(SHAPES)
|
||||
|
||||
// Precompute all 4 rotations of each piece (local coords).
|
||||
const ROTATIONS = {}
|
||||
for (const type of PIECE_TYPES) {
|
||||
let cells = [...SHAPES[type]]
|
||||
const list = []
|
||||
for (let r = 0; r < 4; r++) {
|
||||
const minX = Math.min(...cells.map((c) => c[0]))
|
||||
const minY = Math.min(...cells.map((c) => c[1]))
|
||||
list.push(cells.map(([x, y]) => [x - minX, y - minY]))
|
||||
cells = cells.map(([x, y]) => [-y, x])
|
||||
}
|
||||
ROTATIONS[type] = list
|
||||
}
|
||||
|
||||
export function rotationCells(type, rotation, x = 0, y = 0) {
|
||||
return ROTATIONS[type][((rotation % 4) + 4) % 4].map(([rx, ry]) => [rx + x, ry + y])
|
||||
}
|
||||
|
||||
// Horizontal offset chosen so each piece is centered on a 10-wide board.
|
||||
export function spawnPiece(type) {
|
||||
const w = Math.max(...SHAPES[type].map(([x]) => x)) + 1
|
||||
const x = Math.floor((COLS - w) / 2)
|
||||
return { type, rotation: 0, x, y: 0 }
|
||||
}
|
||||
|
||||
export function createEmptyBoard() {
|
||||
return Array.from({ length: ROWS }, () => Array(COLS).fill(null))
|
||||
}
|
||||
|
||||
export function collides(board, cells) {
|
||||
for (const [x, y] of cells) {
|
||||
if (x < 0 || x >= COLS || y >= ROWS) return true
|
||||
if (y >= 0 && board[y][x] != null) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergePiece(board, cells, type) {
|
||||
const next = board.map((row) => row.slice())
|
||||
for (const [x, y] of cells) {
|
||||
if (y >= 0 && y < ROWS && x >= 0 && x < COLS) next[y][x] = type
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function clearLines(board) {
|
||||
const remaining = board.filter((row) => row.some((cell) => cell == null))
|
||||
const cleared = ROWS - remaining.length
|
||||
while (remaining.length < ROWS) {
|
||||
remaining.unshift(Array(COLS).fill(null))
|
||||
}
|
||||
return { board: remaining, cleared }
|
||||
}
|
||||
|
||||
export function currentCells(piece) {
|
||||
return rotationCells(piece.type, piece.rotation, piece.x, piece.y)
|
||||
}
|
||||
|
||||
export function projectedCells(piece, dx = 0, dy = 0, rotation = piece.rotation) {
|
||||
return rotationCells(piece.type, rotation, piece.x + dx, piece.y + dy)
|
||||
}
|
||||
|
||||
export function ghostCells(piece, board) {
|
||||
const cells = rotationCells(piece.type, piece.rotation, piece.x, piece.y)
|
||||
let y = 0
|
||||
while (!collides(board, cells.map(([cx, cy]) => [cx, cy + y + 1]))) {
|
||||
y++
|
||||
}
|
||||
return cells.map(([cx, cy]) => [cx, cy + y])
|
||||
}
|
||||
|
||||
export const SCORE_TABLE = { 1: 100, 2: 300, 3: 500, 4: 800 }
|
||||
|
||||
export function lineScore(cleared, level) {
|
||||
return (SCORE_TABLE[cleared] ?? 0) * level
|
||||
}
|
||||
|
||||
export function dropSpeed(level) {
|
||||
return Math.max(70, Math.round(1000 * Math.pow(0.85, Math.min(Math.max(level - 1, 0), 10))))
|
||||
}
|
||||
|
||||
export function levelAfterLines(lines) {
|
||||
return Math.floor(lines / 10) + 1
|
||||
}
|
||||
|
||||
export function shuffleBag() {
|
||||
const bag = [...PIECE_TYPES]
|
||||
for (let i = bag.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[bag[i], bag[j]] = [bag[j], bag[i]]
|
||||
}
|
||||
return bag
|
||||
}
|
||||
|
||||
// Standard rotation points for wall kicks. Key: "fromTo".
|
||||
const KICKS = {
|
||||
I: {
|
||||
'0>1': [[-2, 0], [1, 0], [-2, -1], [1, 2]],
|
||||
'1>0': [[2, 0], [-1, 0], [2, 1], [-1, -2]],
|
||||
'1>2': [[-1, 0], [2, 0], [-1, 2], [2, -1]],
|
||||
'2>1': [[1, 0], [-2, 0], [1, -2], [-2, 1]],
|
||||
'2>3': [[2, 0], [-1, 0], [2, 1], [-1, -2]],
|
||||
'3>2': [[-2, 0], [1, 0], [-2, -1], [1, 2]],
|
||||
'3>0': [[1, 0], [-2, 0], [1, -2], [-2, 1]],
|
||||
'0>3': [[-1, 0], [2, 0], [-1, 2], [2, -1]],
|
||||
},
|
||||
O: {},
|
||||
standard: {
|
||||
'0>1': [[-1, 0], [-1, 1], [0, -2], [-1, -2]],
|
||||
'1>0': [[1, 0], [1, -1], [0, 2], [1, 2]],
|
||||
'1>2': [[1, 0], [1, -1], [0, 2], [1, 2]],
|
||||
'2>1': [[-1, 0], [-1, 1], [0, -2], [-1, -2]],
|
||||
'2>3': [[1, 0], [1, 1], [0, -2], [1, -2]],
|
||||
'3>2': [[-1, 0], [-1, -1], [0, 2], [-1, 2]],
|
||||
'3>0': [[-1, 0], [-1, -1], [0, 2], [-1, 2]],
|
||||
'0>3': [[1, 0], [1, 1], [0, -2], [1, -2]],
|
||||
},
|
||||
}
|
||||
|
||||
export function kickOffsets(type, from, to) {
|
||||
const table = type === 'I' ? KICKS.I : type === 'O' ? {} : KICKS.standard
|
||||
return table[`${from}>${to}`] ?? []
|
||||
}
|
||||
|
||||
export function tryRotation(board, piece, toRotation) {
|
||||
const kicks = kickOffsets(piece.type, piece.rotation, toRotation)
|
||||
const candidates = [{ dx: 0, dy: 0 }, ...kicks.map(([dx, dy]) => ({ dx, dy }))]
|
||||
for (const { dx, dy } of candidates) {
|
||||
if (!collides(board, rotationCells(piece.type, toRotation, piece.x + dx, piece.y + dy))) {
|
||||
return { ...piece, rotation: toRotation, x: piece.x + dx, y: piece.y + dy }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useReducer, useRef } from 'react'
|
||||
import {
|
||||
clearLines,
|
||||
collides,
|
||||
createEmptyBoard,
|
||||
currentCells,
|
||||
dropSpeed,
|
||||
ghostCells,
|
||||
levelAfterLines,
|
||||
lineScore,
|
||||
mergePiece,
|
||||
projectedCells,
|
||||
spawnPiece,
|
||||
shuffleBag,
|
||||
tryRotation,
|
||||
} from '../game/tetris'
|
||||
|
||||
const QUEUE_LENGTH = 5
|
||||
const LOCK_DELAY = 3
|
||||
|
||||
function refillQueue(queue) {
|
||||
let q = [...queue]
|
||||
while (q.length < QUEUE_LENGTH) q = [...q, ...shuffleBag()]
|
||||
return q
|
||||
}
|
||||
|
||||
export function initialState() {
|
||||
const queue = refillQueue([])
|
||||
return {
|
||||
board: createEmptyBoard(),
|
||||
current: spawnPiece(queue[0]),
|
||||
queue: queue.slice(1),
|
||||
score: 0,
|
||||
lines: 0,
|
||||
level: 1,
|
||||
status: 'idle',
|
||||
lastClear: 0,
|
||||
rests: LOCK_DELAY,
|
||||
}
|
||||
}
|
||||
|
||||
function lockPiece(state) {
|
||||
const merged = mergePiece(state.board, currentCells(state.current), state.current.type)
|
||||
const { board, cleared } = clearLines(merged)
|
||||
const lines = state.lines + cleared
|
||||
const level = levelAfterLines(lines)
|
||||
const current = spawnPiece(state.queue[0])
|
||||
const next = {
|
||||
...state,
|
||||
board,
|
||||
current,
|
||||
queue: refillQueue(state.queue.slice(1)),
|
||||
score: state.score + lineScore(cleared, state.level),
|
||||
lines,
|
||||
level,
|
||||
lastClear: cleared,
|
||||
rests: LOCK_DELAY,
|
||||
}
|
||||
return collides(board, currentCells(current)) ? { ...next, status: 'over' } : next
|
||||
}
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'START': {
|
||||
if (state.status === 'playing' || state.status === 'paused') return state
|
||||
return { ...initialState(), status: 'playing' }
|
||||
}
|
||||
case 'TICK': {
|
||||
if (state.status !== 'playing') return state
|
||||
if (collides(state.board, projectedCells(state.current, 0, 1))) {
|
||||
if (state.rests > 0) return { ...state, rests: state.rests - 1 }
|
||||
return lockPiece(state)
|
||||
}
|
||||
return move(state, 0, 1)
|
||||
}
|
||||
case 'LEFT':
|
||||
return state.status === 'playing' ? move(state, -1, 0) : state
|
||||
case 'RIGHT':
|
||||
return state.status === 'playing' ? move(state, 1, 0) : state
|
||||
case 'ROTATE': {
|
||||
if (state.status !== 'playing') return state
|
||||
const rotated = tryRotation(state.board, state.current, (state.current.rotation + 1) % 4)
|
||||
return rotated ? { ...state, current: rotated, rests: LOCK_DELAY } : state
|
||||
}
|
||||
case 'SOFT_DROP':
|
||||
return state.status === 'playing' ? move(state, 0, 1) : state
|
||||
case 'HARD_DROP': {
|
||||
if (state.status !== 'playing') return state
|
||||
const drop = dropDistance(state.board, state.current)
|
||||
return drop > 0 ? lockPiece({ ...state, current: { ...state.current, y: state.current.y + drop } }) : lockPiece(state)
|
||||
}
|
||||
case 'TOGGLE_PAUSE': {
|
||||
if (state.status === 'playing') return { ...state, status: 'paused' }
|
||||
if (state.status === 'paused') return { ...state, status: 'playing' }
|
||||
return state
|
||||
}
|
||||
case 'RESTART':
|
||||
return initialState()
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function move(state, dx, dy) {
|
||||
if (!collides(state.board, projectedCells(state.current, dx, dy))) {
|
||||
return {
|
||||
...state,
|
||||
current: { ...state.current, x: state.current.x + dx, y: state.current.y + dy },
|
||||
rests: LOCK_DELAY,
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function dropDistance(board, piece) {
|
||||
let distance = 0
|
||||
while (!collides(board, projectedCells(piece, 0, distance + 1))) {
|
||||
distance++
|
||||
}
|
||||
return distance
|
||||
}
|
||||
|
||||
export function useTetris() {
|
||||
const [state, dispatch] = useReducer(reducer, undefined, initialState)
|
||||
const speed = dropSpeed(state.level)
|
||||
const interval = state.status === 'playing' ? speed : null
|
||||
|
||||
useEffect(() => {
|
||||
if (interval == null) return
|
||||
const id = setInterval(() => dispatch({ type: 'TICK' }), interval)
|
||||
return () => clearInterval(id)
|
||||
}, [interval])
|
||||
|
||||
const actions = useRef({})
|
||||
actions.current.start = useCallback(() => dispatch({ type: 'START' }), [])
|
||||
actions.current.left = useCallback(() => dispatch({ type: 'LEFT' }), [])
|
||||
actions.current.right = useCallback(() => dispatch({ type: 'RIGHT' }), [])
|
||||
actions.current.rotate = useCallback(() => dispatch({ type: 'ROTATE' }), [])
|
||||
actions.current.soft = useCallback(() => dispatch({ type: 'SOFT_DROP' }), [])
|
||||
actions.current.hard = useCallback(() => dispatch({ type: 'HARD_DROP' }), [])
|
||||
actions.current.pause = useCallback(() => dispatch({ type: 'TOGGLE_PAUSE' }), [])
|
||||
actions.current.restart = useCallback(() => dispatch({ type: 'RESTART' }), [])
|
||||
|
||||
const ghost =
|
||||
state.status === 'playing' && state.current ? ghostCells(state.current, state.board) : null
|
||||
|
||||
return { state, actions: actions.current, ghost }
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #161a24;
|
||||
--panel-2: #1d2330;
|
||||
--border: #2a3142;
|
||||
--text: #e6e9f0;
|
||||
--muted: #8b93a7;
|
||||
--accent: #7c6cff;
|
||||
--accent-2: #22d3ee;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 44px;
|
||||
letter-spacing: -1px;
|
||||
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.board {
|
||||
background: #0a0c12;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.cell.filled {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cell.ghost {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 12, 18, 0.82);
|
||||
backdrop-filter: blur(3px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
text-align: center;
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.overlay-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.overlay-subtitle {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 12px 28px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, var(--accent-2), var(--accent));
|
||||
box-shadow: 0 10px 24px rgba(108, 108, 255, 0.35);
|
||||
transition: transform 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.overlay-button:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 184px;
|
||||
}
|
||||
|
||||
.side-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.side-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mini {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.side-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ghost-button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ghost-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.keys {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
min-width: 22px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-width: 2px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.side {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.side-card {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.keys-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 64px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls button:active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user