Spaces:
Running
Running
| (() => { | |
| 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); | |
| })(); |