(() => { const GRID_WIDTH = 16; const GRID_HEIGHT = 16; const FRAME_INTERVAL_MS = 50; const AUTO_DROP_MS = 600; const SOFT_DROP_MS = 60; const canvas = document.getElementById('board'); const ctx = canvas.getContext('2d', { alpha: false }); const scoreEl = document.getElementById('score'); const linesEl = document.getElementById('lines'); const wsDot = document.getElementById('ws-dot'); const wsLabel = document.getElementById('ws-label'); const MATRIX_WIDTH = canvas.width; const MATRIX_HEIGHT = canvas.height; const COLOR_ORDER = 'RGB'; const BRIGHTNESS = 1.0; const SERPENTINE = true; const FLIP_X = true; const FLIP_Y = false; const TETROMINOES = [ { name: 'I', color: '#2dd9ff', rotations: [ [ [0, 1], [1, 1], [2, 1], [3, 1] ], [ [2, 0], [2, 1], [2, 2], [2, 3] ], [ [0, 2], [1, 2], [2, 2], [3, 2] ], [ [1, 0], [1, 1], [1, 2], [1, 3] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'J', color: '#3b7ddd', rotations: [ [ [0, 0], [0, 1], [1, 1], [2, 1] ], [ [1, 0], [2, 0], [1, 1], [1, 2] ], [ [0, 1], [1, 1], [2, 1], [2, 2] ], [ [1, 0], [1, 1], [0, 2], [1, 2] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'L', color: '#f7b733', rotations: [ [ [2, 0], [0, 1], [1, 1], [2, 1] ], [ [1, 0], [1, 1], [1, 2], [2, 2] ], [ [0, 1], [1, 1], [2, 1], [0, 2] ], [ [0, 0], [1, 0], [1, 1], [1, 2] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'O', color: '#ffe66d', rotations: [ [ [1, 0], [2, 0], [1, 1], [2, 1] ], [ [1, 0], [2, 0], [1, 1], [2, 1] ], [ [1, 0], [2, 0], [1, 1], [2, 1] ], [ [1, 0], [2, 0], [1, 1], [2, 1] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'S', color: '#30db6d', rotations: [ [ [1, 0], [2, 0], [0, 1], [1, 1] ], [ [1, 0], [1, 1], [2, 1], [2, 2] ], [ [1, 1], [2, 1], [0, 2], [1, 2] ], [ [0, 0], [0, 1], [1, 1], [1, 2] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'T', color: '#c86bff', rotations: [ [ [1, 0], [0, 1], [1, 1], [2, 1] ], [ [1, 0], [1, 1], [2, 1], [1, 2] ], [ [0, 1], [1, 1], [2, 1], [1, 2] ], [ [1, 0], [0, 1], [1, 1], [1, 2] ], ], spawnOffset: { x: 6, y: -1 }, }, { name: 'Z', color: '#ff5f5f', rotations: [ [ [0, 0], [1, 0], [1, 1], [2, 1] ], [ [2, 0], [1, 1], [2, 1], [1, 2] ], [ [0, 1], [1, 1], [1, 2], [2, 2] ], [ [1, 0], [0, 1], [1, 1], [0, 2] ], ], spawnOffset: { x: 6, y: -1 }, }, ]; const LINE_SCORE_TABLE = { 1: 100, 2: 250, 3: 400, 4: 800, }; let ws; let wsReady = false; let sendingFrame = false; let board; let currentPiece; let holdQueue; let score; let clearedLines; let isGameOver; let dropTimer; let softDropActive; function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${protocol}://${location.host}/ws`; ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; setWsStatus('connecting'); ws.addEventListener('open', () => { wsReady = true; setWsStatus('open'); }); ws.addEventListener('close', () => { wsReady = false; setWsStatus('closed'); }); ws.addEventListener('error', () => { wsReady = false; setWsStatus('error'); }); } function setWsStatus(state) { if (state === 'open') { wsDot.classList.add('ok'); wsLabel.textContent = 'WS: connected'; } else if (state === 'connecting') { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: connecting…'; } else if (state === 'closed') { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: disconnected'; setTimeout(connectWebSocket, 1000); } else { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: error'; } } function clampToByte(value) { if (value < 0) return 0; if (value > 255) return 255; return value | 0; } function applyBrightness(value) { return clampToByte(Math.round(value * BRIGHTNESS)); } function mapXYToLinearIndex(x, y, width, height) { const mappedX = FLIP_X ? (width - 1 - x) : x; const mappedY = FLIP_Y ? (height - 1 - y) : y; const isOddRow = (mappedY % 2) === 1; const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX; return (mappedY * width) + columnInRow; } function writePixelWithColorOrder(target, baseIndex, r, g, b) { switch (COLOR_ORDER) { case 'RGB': target[baseIndex + 0] = r; target[baseIndex + 1] = g; target[baseIndex + 2] = b; break; case 'GRB': target[baseIndex + 0] = g; target[baseIndex + 1] = r; target[baseIndex + 2] = b; break; case 'BRG': target[baseIndex + 0] = b; target[baseIndex + 1] = r; target[baseIndex + 2] = g; break; case 'BGR': target[baseIndex + 0] = b; target[baseIndex + 1] = g; target[baseIndex + 2] = r; break; case 'RBG': target[baseIndex + 0] = r; target[baseIndex + 1] = b; target[baseIndex + 2] = g; break; case 'GBR': target[baseIndex + 0] = g; target[baseIndex + 1] = b; target[baseIndex + 2] = r; break; default: target[baseIndex + 0] = g; target[baseIndex + 1] = r; target[baseIndex + 2] = b; break; } } function encodeCanvasToNeoPixelFrame() { const width = MATRIX_WIDTH; const height = MATRIX_HEIGHT; const out = new Uint8Array(width * height * 3); const src = ctx.getImageData(0, 0, width, height).data; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const srcIndex = ((y * width) + x) * 4; const r = applyBrightness(src[srcIndex + 0]); const g = applyBrightness(src[srcIndex + 1]); const b = applyBrightness(src[srcIndex + 2]); const pixelIndex = mapXYToLinearIndex(x, y, width, height); const base = pixelIndex * 3; writePixelWithColorOrder(out, base, r, g, b); } } return out; } function createEmptyBoard() { const rows = []; for (let y = 0; y < GRID_HEIGHT; y++) { rows.push(new Array(GRID_WIDTH).fill(null)); } return rows; } function makePiece(template) { return { name: template.name, color: template.color, rotations: template.rotations, rotationIndex: 0, x: template.spawnOffset.x, y: template.spawnOffset.y, }; } function getCurrentShape(piece) { return piece.rotations[piece.rotationIndex]; } function forEachBlock(piece, callback) { const shape = getCurrentShape(piece); for (let i = 0; i < shape.length; i++) { const [dx, dy] = shape[i]; callback(piece.x + dx, piece.y + dy); } } function isInside(x, y) { return x >= 0 && x < GRID_WIDTH && y < GRID_HEIGHT; } function isCellFree(x, y) { if (y < 0) { return true; } if (!isInside(x, y)) { return false; } return board[y][x] === null; } function canPlace(piece, offsetX, offsetY, rotationDelta) { const nextRotation = (piece.rotationIndex + rotationDelta + piece.rotations.length) % piece.rotations.length; const shape = piece.rotations[nextRotation]; for (let i = 0; i < shape.length; i++) { const [dx, dy] = shape[i]; const x = piece.x + offsetX + dx; const y = piece.y + offsetY + dy; if (!isCellFree(x, y)) { return false; } } return true; } function commitPieceToBoard(piece) { const shape = getCurrentShape(piece); for (let i = 0; i < shape.length; i++) { const [dx, dy] = shape[i]; const x = piece.x + dx; const y = piece.y + dy; if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) { board[y][x] = piece.color; } } } function clearLines() { let linesCleared = 0; const newRows = []; for (let y = 0; y < GRID_HEIGHT; y++) { if (board[y].every(cell => cell !== null)) { linesCleared += 1; } else { newRows.push(board[y]); } } while (newRows.length < GRID_HEIGHT) { newRows.unshift(new Array(GRID_WIDTH).fill(null)); } board = newRows; if (linesCleared > 0) { clearedLines += linesCleared; const awarded = LINE_SCORE_TABLE[linesCleared] || (linesCleared * 200); score += awarded; } } function spawnNextPiece() { if (holdQueue.length === 0) { refillBag(); } currentPiece = makePiece(holdQueue.shift()); if (!canPlace(currentPiece, 0, 0, 0)) { isGameOver = true; } } function refillBag() { const bag = [...TETROMINOES]; 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]]; } holdQueue.push(...bag); } function tryMove(offsetX, offsetY) { if (!currentPiece || !canPlace(currentPiece, offsetX, offsetY, 0)) { return false; } currentPiece.x += offsetX; currentPiece.y += offsetY; return true; } function tryRotate(clockwise = true) { if (!currentPiece) { return; } const delta = clockwise ? 1 : -1; const originalRotation = currentPiece.rotationIndex; const targetRotation = (originalRotation + delta + currentPiece.rotations.length) % currentPiece.rotations.length; const kicks = [ { x: 0, y: 0 }, { x: -1, y: 0 }, { x: 1, y: 0 }, { x: -2, y: 0 }, { x: 2, y: 0 }, { x: 0, y: -1 }, ]; for (const kick of kicks) { if (canPlace(currentPiece, kick.x, kick.y, delta)) { currentPiece.rotationIndex = targetRotation; currentPiece.x += kick.x; currentPiece.y += kick.y; return; } } } function hardDrop() { if (!currentPiece) { return; } while (tryMove(0, 1)) { score += 2; } lockPiece(); } function lockPiece() { commitPieceToBoard(currentPiece); clearLines(); scoreEl.textContent = String(score); linesEl.textContent = String(clearedLines); spawnNextPiece(); dropTimer = 0; } function updateGame(deltaMs) { if (isGameOver) { return; } dropTimer += deltaMs; const targetDrop = softDropActive ? SOFT_DROP_MS : AUTO_DROP_MS; if (dropTimer >= targetDrop) { dropTimer = 0; if (!tryMove(0, 1)) { lockPiece(); } } } function drawBoard() { ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let y = 0; y < GRID_HEIGHT; y++) { for (let x = 0; x < GRID_WIDTH; x++) { const cell = board[y][x]; if (cell) { ctx.fillStyle = cell; ctx.fillRect(x, y, 1, 1); } } } if (currentPiece) { ctx.fillStyle = currentPiece.color; forEachBlock(currentPiece, (x, y) => { if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) { ctx.fillRect(x, y, 1, 1); } }); } if (isGameOver) { ctx.fillStyle = '#ff3b30'; for (let x = 3; x < GRID_WIDTH - 3; x++) { ctx.fillRect(x, 6, 1, 1); ctx.fillRect(x, 9, 1, 1); } for (let y = 6; y <= 9; y++) { ctx.fillRect(3, y, 1, 1); ctx.fillRect(GRID_WIDTH - 4, y, 1, 1); } } } function toHex(value) { const bounded = Math.max(0, Math.min(255, value)); const hex = bounded.toString(16); return hex.length === 1 ? `0${hex}` : hex; } function sendFrame() { if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) { return; } if (sendingFrame) { return; } sendingFrame = true; try { const frame = encodeCanvasToNeoPixelFrame(); ws.send(frame.buffer); } catch (_) { // ignore } finally { sendingFrame = false; } } let previousTimestamp = performance.now(); function gameLoop() { const now = performance.now(); const delta = now - previousTimestamp; previousTimestamp = now; updateGame(delta); drawBoard(); sendFrame(); setTimeout(gameLoop, FRAME_INTERVAL_MS); } function resetGame() { board = createEmptyBoard(); holdQueue = []; score = 0; clearedLines = 0; isGameOver = false; dropTimer = 0; softDropActive = false; scoreEl.textContent = '0'; linesEl.textContent = '0'; refillBag(); spawnNextPiece(); } window.addEventListener('keydown', (event) => { if (isGameOver && event.key.toLowerCase() === 'r') { resetGame(); return; } switch (event.key) { case 'ArrowLeft': event.preventDefault(); tryMove(-1, 0); break; case 'ArrowRight': event.preventDefault(); tryMove(1, 0); break; case 'ArrowDown': event.preventDefault(); softDropActive = true; break; case 'ArrowUp': event.preventDefault(); tryRotate(true); break; case ' ': // Space hard drop case 'Enter': event.preventDefault(); hardDrop(); break; case 'r': case 'R': event.preventDefault(); resetGame(); break; default: break; } }); window.addEventListener('keyup', (event) => { if (event.key === 'ArrowDown') { softDropActive = false; } }); resetGame(); connectWebSocket(); gameLoop(); })();