keya: initial commit

This commit is contained in:
Kaya Samat
2026-08-07 23:02:12 +03:00
commit e29d55dea1
22 changed files with 2368 additions and 0 deletions
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+16
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
{
"type": "static"
}
+1294
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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

+24
View File
@@ -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
View File
@@ -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

+55
View File
@@ -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>
)
}
+28
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+49
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+160
View File
@@ -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
}
+148
View File
@@ -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
View File
@@ -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;
}
}
+10
View File
@@ -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>,
)
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})