(() => { const WIDTH = 6; const HEIGHT = 12; const CELL = 40; const PARTICLES_PER_BLOCK = 10; const PARTICLE_LIFETIME = 600; const UNKNOWN_PROBABILITY = 0.3; const SCORE_PER_BLOCK = 10; const COLORS = [ {key: 'R', fill: '#d33'}, {key: 'G', fill: '#3c3'}, {key: 'B', fill: '#36f'}, {key: 'Y', fill: '#ee3'}, ]; const COLOR_MAP = Object.fromEntries(COLORS.map(c => [c.key, c.fill])); const OFFSETS = [ {x: 0, y: 1}, {x: 1, y: 0}, {x: 0, y: -1}, {x: -1, y: 0}, ]; class QuantumBlock { constructor(amplitudes) { this.amplitudes = amplitudes || Object.fromEntries(COLORS.map(c => [c.key, 1 / COLORS.length])); this.collapsedColor = null; } isCollapsed() { return this.collapsedColor !== null; } collapse() { if (this.collapsedColor) return this.collapsedColor; const r = Math.random(); let sum = 0; for (const [key, prob] of Object.entries(this.amplitudes)) { sum += prob; if (r < sum) { this.collapsedColor = key; break; } } if (!this.collapsedColor) this.collapsedColor = COLORS[COLORS.length - 1].key; return this.collapsedColor; } } class FallingPair { constructor(unknownProb = UNKNOWN_PROBABILITY) { this.x = Math.floor(WIDTH / 2); this.y = -1; this.orientation = 0; this.a = new QuantumBlock(); this.a.collapse(); this.b = new QuantumBlock(); if (Math.random() > unknownProb) { this.b.collapse(); } } blocks() { const {x: dx, y: dy} = OFFSETS[this.orientation]; return [ {x: this.x, y: this.y, block: this.a}, {x: this.x + dx, y: this.y + dy, block: this.b}, ]; } rotateCW() { this.orientation = (this.orientation + 1) % 4; } rotateCCW() { this.orientation = (this.orientation + 3) % 4; } } class Game { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.board = Array.from({length: HEIGHT}, () => Array(WIDTH).fill(null)); this.particles = []; this.pair = new FallingPair(); this.nextPair = new FallingPair(); this.nextCanvas = document.getElementById('nextCanvas'); this.nextCtx = this.nextCanvas ? this.nextCanvas.getContext('2d') : null; if (this.nextCanvas) { this.nextCanvas.width = CELL * 1; this.nextCanvas.height = CELL * 2; } this.score = 0; this.scoreElem = document.getElementById('score'); this.updateScoreUI(); this.lastFall = performance.now(); this.fallInterval = 500; this.running = true; this.bindKeys(); requestAnimationFrame(this.loop.bind(this)); this.drawNextPreview(); } inBounds(x, y) { return x >= 0 && x < WIDTH && y >= -1 && y < HEIGHT; } fits(pair) { for (const {x, y} of pair.blocks()) { if (!this.inBounds(x, y)) return false; if (y >= 0 && this.board[y][x]) return false; } return true; } lockPair() { const landedBlocks = this.pair.blocks(); for (const {x, y, block} of landedBlocks) { if (y < 0) { this.gameOver(); return; } this.board[y][x] = block; } for (const {block} of landedBlocks) { if (!block.isCollapsed()) block.collapse(); } this.applyGravity(); this.resolveChains(); this.pair = this.nextPair; this.nextPair = new FallingPair(); this.drawNextPreview(); if (!this.fits(this.pair)) { this.gameOver(); } } applyGravity() { let moved; do { moved = false; for (let y = HEIGHT - 2; y >= 0; --y) { for (let x = 0; x < WIDTH; ++x) { const block = this.board[y][x]; if (block && !this.board[y + 1][x]) { this.board[y + 1][x] = block; this.board[y][x] = null; moved = true; } } } } while (moved); } measureAll() { for (const row of this.board) { for (const block of row) if (block) block.collapse(); } for (const {block} of this.pair.blocks()) block.collapse(); this.resolveChains(); } resolveChains() { const dirs = [ [1, 0], [-1, 0], [0, 1], [0, -1], ]; let popped; do { popped = false; const visited = Array.from({length: HEIGHT}, () => Array(WIDTH).fill(false)); let poppedThisRound = 0; for (let y = 0; y < HEIGHT; ++y) { for (let x = 0; x < WIDTH; ++x) { if (visited[y][x] || !this.board[y][x]) continue; const color = this.board[y][x].collapsedColor; const group = []; const stack = [[x, y]]; while (stack.length) { const [cx, cy] = stack.pop(); if (cx < 0 || cx >= WIDTH || cy < 0 || cy >= HEIGHT) continue; if (visited[cy][cx]) continue; const cell = this.board[cy][cx]; if (!cell || cell.collapsedColor !== color) continue; visited[cy][cx] = true; group.push([cx, cy]); for (const [dx, dy] of dirs) stack.push([cx + dx, cy + dy]); } if (group.length >= 4) { popped = true; for (const [gx, gy] of group) { this.board[gy][gx] = null; this.spawnParticles(gx, gy, color); } poppedThisRound += group.length; } } } if (popped) { if (poppedThisRound > 0) { this.addScore(poppedThisRound * SCORE_PER_BLOCK); } this.applyGravity(); } } while (popped); } addScore(amount) { this.score += amount; this.updateScoreUI(); } updateScoreUI() { if (this.scoreElem) { this.scoreElem.textContent = `Score: ${this.score}`; } } update(dt) { const now = performance.now(); this.particles = this.particles.filter(p => now - p.birth < PARTICLE_LIFETIME); for (const p of this.particles) { const age = now - p.birth; const t = age / PARTICLE_LIFETIME; p.x += p.vx; p.y += p.vy; p.vy += 0.05; p.alpha = 1 - t; } if (now - this.lastFall >= this.fallInterval) { this.pair.y += 1; if (!this.fits(this.pair)) { this.pair.y -= 1; this.lockPair(); } this.lastFall = now; } } draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); for (let y = 0; y < HEIGHT; ++y) { for (let x = 0; x < WIDTH; ++x) { const block = this.board[y][x]; if (block) this.drawBlock(x, y, block); } } for (const {x, y, block} of this.pair.blocks()) { if (y >= 0) this.drawBlock(x, y, block, true); } for (const p of this.particles) { ctx.save(); ctx.globalAlpha = p.alpha; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } ctx.strokeStyle = '#555'; for (let x = 0; x <= WIDTH; ++x) { ctx.beginPath(); ctx.moveTo(x * CELL, 0); ctx.lineTo(x * CELL, HEIGHT * CELL); ctx.stroke(); } for (let y = 0; y <= HEIGHT; ++y) { ctx.beginPath(); ctx.moveTo(0, y * CELL); ctx.lineTo(WIDTH * CELL, y * CELL); ctx.stroke(); } this.drawNextPreview(); } drawNextPreview() { if (!this.nextCtx || !this.nextCanvas) return; const ctx = this.nextCtx; const CANVAS_W = this.nextCanvas.width; const CANVAS_H = this.nextCanvas.height; ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); const previewPair = this.nextPair; if (!previewPair) return; const blocks = previewPair.blocks(); blocks.forEach(({block}, idx) => { const px = 0; const py = idx * CELL; ctx.save(); if (block.isCollapsed()) { ctx.fillStyle = COLOR_MAP[block.collapsedColor] || '#888'; ctx.fillRect(px + 2, py + 2, CELL - 4, CELL - 4); } else { ctx.fillStyle = '#666'; ctx.fillRect(px + 4, py + 4, CELL - 8, CELL - 8); ctx.fillStyle = '#ddd'; ctx.font = '20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('?', px + CELL / 2, py + CELL / 2 + 1); } ctx.restore(); }); ctx.strokeStyle = '#555'; for (let gx = 0; gx <= 1; ++gx) { ctx.beginPath(); ctx.moveTo(gx * CELL, 0); ctx.lineTo(gx * CELL, CELL * 2); ctx.stroke(); } for (let gy = 0; gy <= 2; ++gy) { ctx.beginPath(); ctx.moveTo(0, gy * CELL); ctx.lineTo(CELL, gy * CELL); ctx.stroke(); } } drawBlock(x, y, block, outline = false) { const ctx = this.ctx; const px = x * CELL; const py = y * CELL; ctx.save(); if (block.isCollapsed()) { ctx.fillStyle = COLOR_MAP[block.collapsedColor] || '#888'; ctx.fillRect(px + 2, py + 2, CELL - 4, CELL - 4); } else { ctx.fillStyle = '#666'; ctx.fillRect(px + 4, py + 4, CELL - 8, CELL - 8); ctx.fillStyle = '#ddd'; ctx.font = '20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('?', px + CELL / 2, py + CELL / 2 + 1); } if (outline) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.strokeRect(px + 1, py + 1, CELL - 2, CELL - 2); } ctx.restore(); } loop(now) { if (!this.running) return; this.update(now); this.draw(); requestAnimationFrame(this.loop.bind(this)); } bindKeys() { window.addEventListener('keydown', (e) => { if (e.key === 'q' || e.key === 'Q') { location.reload(); return; } if (!this.running) return; switch (e.key) { case 'ArrowLeft': this.pair.x -= 1; if (!this.fits(this.pair)) this.pair.x += 1; break; case 'ArrowRight': this.pair.x += 1; if (!this.fits(this.pair)) this.pair.x -= 1; break; case 'ArrowDown': this.pair.y += 1; if (!this.fits(this.pair)) { this.pair.y -= 1; this.lockPair(); } break; case 'ArrowUp': this.pair.rotateCW(); if (!this.fits(this.pair)) this.pair.rotateCCW(); break; case 'm': case 'M': case ' ': this.measureAll(); break; } }); } gameOver() { this.running = false; this.draw(); const ctx = this.ctx; ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, this.canvas.height / 2 - 40, this.canvas.width, 80); ctx.fillStyle = '#fff'; ctx.font = '28px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('GAME OVER', this.canvas.width / 2, this.canvas.height / 2 - 14); ctx.font = '16px monospace'; ctx.fillText(`Score: ${this.score}`, this.canvas.width / 2, this.canvas.height / 2 + 10); ctx.fillText('Press Q to restart', this.canvas.width / 2, this.canvas.height / 2 + 32); } } Game.prototype.spawnParticles = function(gridX, gridY, colorKey) { const color = COLOR_MAP[colorKey] || '#fff'; const baseX = gridX * CELL + CELL / 2; const baseY = gridY * CELL + CELL / 2; const now = performance.now(); for (let i = 0; i < PARTICLES_PER_BLOCK; ++i) { const angle = Math.random() * Math.PI * 2; const speed = Math.random() * 2 + 1; this.particles.push({ x: baseX, y: baseY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, radius: Math.random() * 3 + 1, color, birth: now, alpha: 1, }); } }; const canvas = document.getElementById('gameCanvas'); canvas.width = WIDTH * CELL; canvas.height = HEIGHT * CELL; new Game(canvas); })();