Rewrite Tetris overlay with real tetrominoes (I/O/T/S/Z/J/L shapes)

White cells with colored borders, proper grid-based stacking collision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 20:39:17 +03:00
parent 6e5b042f57
commit fb067816b0

View File

@@ -15,33 +15,17 @@ const DEMO_EMAILS = [
] ]
// ── Tetris overlay ───────────────────────────────────────────────────────────── // ── Tetris overlay ─────────────────────────────────────────────────────────────
const TETRIS_LABELS = [
'Шахматка', 'Бронирования', 'Гости', 'Номера', 'Уборка',
'Каналы', 'Отчёты', 'Веб-сайт', 'Виджет', 'Касса',
'Отзывы', 'Сервис', 'Аренда', 'Тарифы', 'Скидки',
'Лояльность', 'Тех.перерывы', 'Цены', 'Документы', 'Модули',
]
const TETRIS_COLORS = [
'#1e3a8a', '#14532d', '#713f12', '#3b0764', '#0f172a',
'#134e4a', '#4a044e', '#1c1917', '#7f1d1d', '#0c4a6e',
]
function roundRectPath( // All 7 tetrominoes with all rotations, each cell = [row, col] offset
ctx: CanvasRenderingContext2D, const TETROMINOES: { cells: [number,number][][]; color: string }[] = [
x: number, y: number, w: number, h: number, r: number, { color: '#06b6d4', cells: [[[0,0],[0,1],[0,2],[0,3]], [[0,0],[1,0],[2,0],[3,0]]] }, // I
) { { color: '#eab308', cells: [[[0,0],[0,1],[1,0],[1,1]]] }, // O
ctx.beginPath() { color: '#a855f7', cells: [[[0,1],[1,0],[1,1],[1,2]], [[0,0],[1,0],[1,1],[2,0]], [[0,0],[0,1],[0,2],[1,1]], [[0,1],[1,0],[1,1],[2,1]]] }, // T
ctx.moveTo(x + r, y) { color: '#22c55e', cells: [[[0,1],[0,2],[1,0],[1,1]], [[0,0],[1,0],[1,1],[2,1]]] }, // S
ctx.lineTo(x + w - r, y) { color: '#ef4444', cells: [[[0,0],[0,1],[1,1],[1,2]], [[0,1],[1,0],[1,1],[2,0]]] }, // Z
ctx.arcTo(x + w, y, x + w, y + r, r) { color: '#3b82f6', cells: [[[0,0],[1,0],[1,1],[1,2]], [[0,0],[0,1],[1,0],[2,0]], [[0,0],[0,1],[0,2],[1,2]], [[0,1],[1,1],[2,0],[2,1]]] }, // J
ctx.lineTo(x + w, y + h - r) { color: '#f97316', cells: [[[0,2],[1,0],[1,1],[1,2]], [[0,0],[1,0],[2,0],[2,1]], [[0,0],[0,1],[0,2],[1,0]], [[0,0],[0,1],[1,1],[2,1]]] }, // L
ctx.arcTo(x + w, y + h, x + w - r, y + h, r) ]
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
function TetrisOverlay() { function TetrisOverlay() {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -51,91 +35,122 @@ function TetrisOverlay() {
const delay = setTimeout(() => { const delay = setTimeout(() => {
const canvas = canvasRef.current const canvas = canvasRef.current
console.log('[Tetris] canvas ref:', canvas)
if (!canvas) return if (!canvas) return
const parent = canvas.parentElement const parent = canvas.parentElement
console.log('[Tetris] parent:', parent, 'size:', parent?.clientWidth, 'x', parent?.clientHeight)
if (!parent) return if (!parent) return
const W = parent.clientWidth const W = parent.clientWidth
const H = parent.clientHeight const H = parent.clientHeight
if (!W || !H) return
canvas.width = W canvas.width = W
canvas.height = H canvas.height = H
console.log('[Tetris] started, canvas size:', W, 'x', H)
const TILE_W = 84 const CELL = 32
const TILE_H = 252 // 3× ratio const COLS = Math.floor(W / CELL)
const GAP = 6 const ROWS = Math.ceil(H / CELL)
const cols = Math.max(1, Math.floor(W / (TILE_W + GAP))) // offset to center the grid
const offsetX = (W - cols * (TILE_W + GAP) + GAP) / 2 const OX = Math.floor((W - COLS * CELL) / 2)
const colX = (i: number) => offsetX + i * (TILE_W + GAP)
// Tracks bottom y of the topmost settled tile per column (starts at canvas bottom) // settled[row][col] = border color | null
const colFloor = new Array<number>(cols).fill(H) const settled: (string | null)[][] = Array.from({ length: ROWS + 6 }, () => new Array(COLS).fill(null))
interface Tile { col: number; y: number; vy: number; label: string; color: string }
const falling: Tile[] = []
const settled: Tile[] = []
let spawnIn = 300
let last = performance.now()
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
const drawTile = (x: number, y: number, label: string, color: string) => { // Draw one tetromino cell: white fill, colored border
roundRectPath(ctx, x, y, TILE_W, TILE_H, 10) const drawCell = (col: number, rowPx: number, color: string) => {
ctx.fillStyle = color const x = OX + col * CELL + 1
const y = rowPx + 1
const s = CELL - 2
ctx.beginPath()
ctx.roundRect(x, y, s, s, 3)
ctx.fillStyle = 'rgba(255,255,255,0.88)'
ctx.fill() ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.28)' ctx.strokeStyle = color
ctx.lineWidth = 1.5 ctx.lineWidth = 2.5
ctx.stroke() ctx.stroke()
}
// Text rotated 90° so it reads along the long axis interface Piece {
ctx.save() cells: [number,number][]
ctx.translate(x + TILE_W / 2, y + TILE_H / 2) color: string
ctx.rotate(-Math.PI / 2) col: number // grid-col of leftmost cell
ctx.fillStyle = 'rgba(255,255,255,0.92)' yPx: number // pixel Y of top row
ctx.font = 'bold 13px system-ui,sans-serif' vy: number
ctx.textAlign = 'center' }
ctx.textBaseline = 'middle'
ctx.fillText(label, 0, 0) const falling: Piece[] = []
ctx.restore() let spawnIn = 300
let last = performance.now()
const maxRow = (cells: [number,number][]) => Math.max(...cells.map(c => c[0]))
const maxCol = (cells: [number,number][]) => Math.max(...cells.map(c => c[1]))
const collides = (cells: [number,number][], col: number, gridRow: number) => {
for (const [r, c] of cells) {
const gr = gridRow + r
const gc = col + c
if (gr >= ROWS || gc < 0 || gc >= COLS) return true
if (gr >= 0 && settled[gr][gc] !== null) return true
}
return false
}
const settle = (p: Piece, gridRow: number) => {
for (const [r, c] of p.cells) {
const gr = gridRow + r
const gc = p.col + c
if (gr >= 0 && gr < ROWS && gc >= 0 && gc < COLS)
settled[gr][gc] = p.color
}
} }
const tick = (now: number) => { const tick = (now: number) => {
const dt = Math.min(now - last, 50) const dt = Math.min(now - last, 50)
last = now last = now
spawnIn -= dt spawnIn -= dt
ctx.clearRect(0, 0, W, H) ctx.clearRect(0, 0, W, H)
// Spawn // Spawn new piece
if (spawnIn <= 0 && falling.length < 4) { if (spawnIn <= 0 && falling.length < 3) {
const free = Array.from({ length: cols }, (_, i) => i).filter(i => colFloor[i] > TILE_H) const tIdx = Math.floor(Math.random() * TETROMINOES.length)
if (free.length > 0) { const t = TETROMINOES[tIdx]
const col = free[Math.floor(Math.random() * free.length)] const rIdx = Math.floor(Math.random() * t.cells.length)
const label = TETRIS_LABELS[Math.floor(Math.random() * TETRIS_LABELS.length)] const cells = t.cells[rIdx]
const color = TETRIS_COLORS[Math.floor(Math.random() * TETRIS_COLORS.length)] const mxc = maxCol(cells)
falling.push({ col, y: -TILE_H, vy: 1.4 + Math.random() * 0.8, label, color }) const maxStartCol = COLS - mxc - 1
if (maxStartCol >= 0) {
const col = Math.floor(Math.random() * (maxStartCol + 1))
if (!collides(cells, col, 0)) {
falling.push({
cells,
color: t.color,
col,
yPx: -(maxRow(cells) + 1) * CELL,
vy: 1.2 + Math.random() * 0.8,
})
} }
spawnIn = 500 + Math.random() * 500 }
spawnIn = 700 + Math.random() * 500
} }
// Draw settled // Draw settled cells
for (const t of settled) drawTile(colX(t.col), t.y, t.label, t.color) for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
if (settled[r][c]) drawCell(c, r * CELL, settled[r][c]!)
// Update + draw falling // Update + draw falling pieces
for (let i = falling.length - 1; i >= 0; i--) { for (let i = falling.length - 1; i >= 0; i--) {
const t = falling[i] const p = falling[i]
t.y += t.vy * dt / 16 p.yPx += p.vy * dt / 16
const land = colFloor[t.col] - TILE_H const gridRow = Math.floor(p.yPx / CELL)
if (t.y >= land) {
t.y = land if (collides(p.cells, p.col, gridRow + 1)) {
colFloor[t.col] = land const snapRow = Math.max(0, gridRow)
settled.push({ ...t }) settle(p, snapRow)
falling.splice(i, 1) falling.splice(i, 1)
} else { } else {
drawTile(colX(t.col), t.y, t.label, t.color) for (const [r, c] of p.cells)
drawCell(p.col + c, p.yPx + r * CELL, p.color)
} }
} }
@@ -143,7 +158,7 @@ function TetrisOverlay() {
} }
rafId = requestAnimationFrame(tick) rafId = requestAnimationFrame(tick)
}, 500) }, 5000)
return () => { return () => {
clearTimeout(delay) clearTimeout(delay)