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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user