Add tetris
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
dvirlabs 2026-06-05 19:10:46 +03:00
parent 3d4e9c3a25
commit 1cd38b6cba
9 changed files with 605 additions and 70 deletions

9
bash.exe.stackdump Normal file
View File

@ -0,0 +1,9 @@
Stack trace:
Frame Function Args
000FFFF9F70 00210062B0E (00210297178, 00210275E3E, 000FFFF9F70, 000FFFF8E70)
000FFFF9F70 0021004846A (00000000000, 00000000000, 00000000000, 00000000004)
000FFFF9F70 002100484A2 (00210297229, 000FFFF9E28, 000FFFF9F70, 00000000000)
000FFFF9F70 002100D2FFE (00000000000, 00000000000, 00000000000, 00000000000)
000FFFF9F70 002100D3125 (000FFFF9F80, 00000000000, 00000000000, 00000000000)
001004F84B7 002100D46E5 (000FFFF9F80, 00000000000, 00000000000, 00000000000)
End of stack trace

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

67
dist/assets/index-DavgIoPG.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080c14" />
<title>ErrorLab</title>
<script type="module" crossorigin src="/assets/index-BmIETw7i.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DmKezAnz.css">
<script type="module" crossorigin src="/assets/index-DavgIoPG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMF9Bv1T.css">
</head>
<body>
<div id="root"></div>

View File

@ -0,0 +1,149 @@
.tetris-shell {
width: 100%;
max-width: 700px;
background: linear-gradient(180deg, rgba(10, 20, 32, 0.85) 0%, rgba(6, 12, 18, 0.92) 100%);
border: 1px solid rgba(0, 200, 255, 0.2);
border-radius: 10px;
padding: 1rem;
position: relative;
box-shadow: 0 0 28px rgba(0, 200, 255, 0.1);
}
.tetris-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
margin-bottom: 0.9rem;
}
.tetris-title {
font-size: 1rem;
letter-spacing: 0.08em;
color: var(--accent);
}
.tetris-metrics {
display: flex;
gap: 0.8rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.tetris-layout {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.tetris-board {
width: 100%;
max-width: 300px;
aspect-ratio: 1 / 2;
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(20, 1fr);
gap: 2px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 3px;
}
.tetris-cell {
background: rgba(255, 255, 255, 0.025);
}
.tetris-cell[style] {
background: var(--cell-color);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.tetris-cell--ghost {
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.tetris-sidebar {
min-width: 130px;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.tetris-label {
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.tetris-next-grid {
width: 96px;
height: 96px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 2px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.09);
padding: 4px;
}
.tetris-next-cell {
background: rgba(255, 255, 255, 0.03);
}
.tetris-next-cell--on {
background: var(--cell-color);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.tetris-btn {
border: 1px solid var(--accent);
background: rgba(0, 255, 204, 0.1);
color: var(--accent);
border-radius: 6px;
padding: 0.45rem 0.6rem;
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.tetris-btn:hover {
background: rgba(0, 255, 204, 0.18);
}
.tetris-help {
margin-top: 0.8rem;
font-size: 0.7rem;
color: var(--text-muted);
}
.tetris-overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: rgba(6, 10, 16, 0.55);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.tetris-overlay p {
color: #ffffff;
font-size: 1rem;
letter-spacing: 0.08em;
}
@media (max-width: 640px) {
.tetris-layout {
flex-direction: column;
align-items: center;
}
.tetris-sidebar {
width: 100%;
min-width: 0;
align-items: center;
}
}

View File

@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import './TetrisGame.css'
const BOARD_W = 10
const BOARD_H = 20
const TICK_MS = 500
const SHAPES = {
I: {
color: '#00ccff',
matrix: [
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0],
],
},
O: {
color: '#ffee44',
matrix: [
[1, 1],
[1, 1],
],
},
T: {
color: '#b266ff',
matrix: [
[0, 1, 0],
[1, 1, 1],
[0, 0, 0],
],
},
L: {
color: '#ffaa33',
matrix: [
[0, 0, 1],
[1, 1, 1],
[0, 0, 0],
],
},
J: {
color: '#4488ff',
matrix: [
[1, 0, 0],
[1, 1, 1],
[0, 0, 0],
],
},
S: {
color: '#33dd66',
matrix: [
[0, 1, 1],
[1, 1, 0],
[0, 0, 0],
],
},
Z: {
color: '#ff5566',
matrix: [
[1, 1, 0],
[0, 1, 1],
[0, 0, 0],
],
},
}
const PIECES = Object.keys(SHAPES)
function newBoard() {
return Array.from({ length: BOARD_H }, () => Array.from({ length: BOARD_W }, () => null))
}
function cloneBoard(board) {
return board.map((row) => [...row])
}
function rotateMatrix(matrix) {
const n = matrix.length
return Array.from({ length: n }, (_, y) =>
Array.from({ length: n }, (_, x) => matrix[n - 1 - x][y])
)
}
function randomPiece() {
const type = PIECES[Math.floor(Math.random() * PIECES.length)]
const shape = SHAPES[type]
return {
type,
color: shape.color,
matrix: shape.matrix.map((r) => [...r]),
x: Math.floor((BOARD_W - shape.matrix[0].length) / 2),
y: -1,
}
}
function collides(board, piece) {
const { matrix, x: px, y: py } = piece
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (!matrix[y][x]) continue
const bx = px + x
const by = py + y
if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true
if (by >= 0 && board[by][bx]) return true
}
}
return false
}
function mergePiece(board, piece) {
const next = cloneBoard(board)
const { matrix, x: px, y: py, color } = piece
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (!matrix[y][x]) continue
const by = py + y
const bx = px + x
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
next[by][bx] = color
}
}
}
return next
}
function clearLines(board) {
const kept = board.filter((row) => row.some((cell) => !cell))
const removed = BOARD_H - kept.length
const cleared = [
...Array.from({ length: removed }, () => Array.from({ length: BOARD_W }, () => null)),
...kept,
]
return { board: cleared, removed }
}
function ghostDropY(board, piece) {
let y = piece.y
while (!collides(board, { ...piece, y: y + 1 })) y += 1
return y
}
export default function TetrisGame() {
const [board, setBoard] = useState(() => newBoard())
const [piece, setPiece] = useState(() => randomPiece())
const [nextPiece, setNextPiece] = useState(() => randomPiece())
const [score, setScore] = useState(0)
const [lines, setLines] = useState(0)
const [status, setStatus] = useState('idle')
const statusRef = useRef(status)
useEffect(() => {
statusRef.current = status
}, [status])
const resetGame = useCallback(() => {
setBoard(newBoard())
setPiece(randomPiece())
setNextPiece(randomPiece())
setScore(0)
setLines(0)
setStatus('playing')
}, [])
const lockAndSpawn = useCallback((fallingPiece) => {
setBoard((prevBoard) => {
const merged = mergePiece(prevBoard, fallingPiece)
const { board: clearedBoard, removed } = clearLines(merged)
if (removed > 0) {
setLines((v) => v + removed)
setScore((v) => v + removed * 100)
}
return clearedBoard
})
setPiece((_) => {
const incoming = {
...nextPiece,
matrix: nextPiece.matrix.map((row) => [...row]),
x: Math.floor((BOARD_W - nextPiece.matrix[0].length) / 2),
y: -1,
}
setNextPiece(randomPiece())
setBoard((latestBoard) => {
if (collides(latestBoard, incoming)) {
setStatus('gameover')
return latestBoard
}
return latestBoard
})
return incoming
})
}, [nextPiece])
const tryMove = useCallback((dx, dy) => {
if (statusRef.current !== 'playing') return
setPiece((prev) => {
const moved = { ...prev, x: prev.x + dx, y: prev.y + dy }
if (!collides(board, moved)) return moved
if (dy > 0 && dx === 0) {
lockAndSpawn(prev)
}
return prev
})
}, [board, lockAndSpawn])
const hardDrop = useCallback(() => {
if (statusRef.current !== 'playing') return
const dropY = ghostDropY(board, piece)
const landed = { ...piece, y: dropY }
setPiece(landed)
lockAndSpawn(landed)
}, [board, piece, lockAndSpawn])
const rotatePiece = useCallback(() => {
if (statusRef.current !== 'playing') return
setPiece((prev) => {
const rotated = { ...prev, matrix: rotateMatrix(prev.matrix) }
if (!collides(board, rotated)) return rotated
const kickLeft = { ...rotated, x: rotated.x - 1 }
if (!collides(board, kickLeft)) return kickLeft
const kickRight = { ...rotated, x: rotated.x + 1 }
if (!collides(board, kickRight)) return kickRight
return prev
})
}, [board])
useEffect(() => {
if (status !== 'playing') return undefined
const id = setInterval(() => {
tryMove(0, 1)
}, TICK_MS)
return () => clearInterval(id)
}, [status, tryMove])
useEffect(() => {
const onKey = (e) => {
if (e.code === 'Space' && statusRef.current !== 'playing') {
e.preventDefault()
resetGame()
return
}
if (statusRef.current !== 'playing') return
if (e.code === 'ArrowLeft') {
e.preventDefault()
tryMove(-1, 0)
} else if (e.code === 'ArrowRight') {
e.preventDefault()
tryMove(1, 0)
} else if (e.code === 'ArrowDown') {
e.preventDefault()
tryMove(0, 1)
} else if (e.code === 'ArrowUp') {
e.preventDefault()
rotatePiece()
} else if (e.code === 'Space') {
e.preventDefault()
hardDrop()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [hardDrop, resetGame, rotatePiece, tryMove])
const ghostY = useMemo(() => ghostDropY(board, piece), [board, piece])
const renderGrid = useMemo(() => {
const grid = cloneBoard(board)
for (let y = 0; y < piece.matrix.length; y++) {
for (let x = 0; x < piece.matrix[y].length; x++) {
if (!piece.matrix[y][x]) continue
const by = ghostY + y
const bx = piece.x + x
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W && !grid[by][bx]) {
grid[by][bx] = 'ghost'
}
}
}
for (let y = 0; y < piece.matrix.length; y++) {
for (let x = 0; x < piece.matrix[y].length; x++) {
if (!piece.matrix[y][x]) continue
const by = piece.y + y
const bx = piece.x + x
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
grid[by][bx] = piece.color
}
}
}
return grid
}, [board, ghostY, piece])
return (
<section className="tetris-shell" aria-label="Tetris mini game">
<header className="tetris-header">
<h3 className="tetris-title">Tetris</h3>
<div className="tetris-metrics">
<span>SCORE: {String(score).padStart(4, '0')}</span>
<span>LINES: {lines}</span>
</div>
</header>
<div className="tetris-layout">
<div className="tetris-board" role="img" aria-label="Tetris board">
{renderGrid.map((row, y) =>
row.map((cell, x) => {
const key = `${y}-${x}`
const className = cell === 'ghost' ? 'tetris-cell tetris-cell--ghost' : 'tetris-cell'
const style = cell && cell !== 'ghost' ? { '--cell-color': cell } : undefined
return <div key={key} className={className} style={style} />
})
)}
</div>
<aside className="tetris-sidebar">
<div className="tetris-next">
<p className="tetris-label">NEXT</p>
<div className="tetris-next-grid">
{nextPiece.matrix.map((row, y) =>
row.map((filled, x) => (
<div
key={`n-${y}-${x}`}
className={filled ? 'tetris-next-cell tetris-next-cell--on' : 'tetris-next-cell'}
style={filled ? { '--cell-color': nextPiece.color } : undefined}
/>
))
)}
</div>
</div>
<button className="tetris-btn" type="button" onClick={resetGame}>
{status === 'playing' ? 'Restart' : 'Start'}
</button>
</aside>
</div>
<p className="tetris-help">
controls: left/right move, up rotate, down soft drop, space hard drop/start
</p>
{status !== 'playing' && (
<div className="tetris-overlay" aria-hidden="true">
<p>{status === 'gameover' ? 'GAME OVER' : 'PRESS START'}</p>
</div>
)}
</section>
)
}

View File

@ -76,3 +76,18 @@
.home-footer-text { font-size: 0.82rem; color: var(--text-muted); }
.home-link { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.home-link:hover { color: var(--cyan); }
.home-tetris {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.7rem;
margin-top: 0.3rem;
}
.home-tetris-title {
font-size: 0.92rem;
color: var(--cyan);
letter-spacing: 0.08em;
}

View File

@ -1,4 +1,5 @@
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
import TetrisGame from '../../components/TetrisGame/TetrisGame.jsx'
import './HomePage.css'
/**
@ -37,6 +38,11 @@ export default function HomePage() {
<a href="/404" className="home-link">See the 404 game</a>
</span>
</footer>
<section className="home-tetris" aria-label="Tetris mini game">
<h2 className="home-tetris-title">Mini Game: Tetris</h2>
<TetrisGame />
</section>
</div>
</PageLayout>
)