diff --git a/public/assets/fx/scifi-plink.mp3 b/public/assets/fx/scifi-plink.mp3 new file mode 100644 index 0000000..1833fa2 Binary files /dev/null and b/public/assets/fx/scifi-plink.mp3 differ diff --git a/public/assets/fx/scifi-plonk.mp3 b/public/assets/fx/scifi-plonk.mp3 new file mode 100644 index 0000000..3fbfb17 Binary files /dev/null and b/public/assets/fx/scifi-plonk.mp3 differ diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 57013ab..e897ea0 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 6d34399..9a5136d 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/2048/2048Game.js b/public/src/games/2048/2048Game.js new file mode 100644 index 0000000..9aad8a3 --- /dev/null +++ b/public/src/games/2048/2048Game.js @@ -0,0 +1,689 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + createState, spawnTile, applyMove, hasValidMoves, + buildTilePalette, buildTextColorTable, getTileLevel, +} from './2048Logic.js'; + +const D = { bg: 0, board: 2, tiles: 5, tileText: 6, fx: 10, ui: 20, picker: 30, overlay: 40, overlayUI: 42 }; + +const FONT_TITLE = 'Righteous'; +const FONT_LABEL = '"Julius Sans One"'; + +// Font size multipliers by tile value level (1-indexed by log2(val)) +const FS_LEVELS = { 1:36, 2:36, 3:36, 4:36, 5:36, 6:36, 7:30, 8:26, 9:22, 10:20, 11:18 }; + +export default class Game2048 extends Phaser.Scene { + constructor() { super('2048Game'); } + + init(data) { + this.gameDef = data?.game ?? { slug: '2048', name: '2048' }; + + this.schemes = []; + this.selectedScheme = null; + this.pickerObjs = []; + this.overlayObjs = []; + + this.state = null; + this.tilePalette = []; + this.tileTextColors = []; + this.tileContainers = []; + this.tileGraphics = []; + this.tileTexts = []; + + this.busy = false; + this.gameOver = false; + this.wonAlready = false; + this.score = 0; + this.best = 0; + + this.scoreValueText = null; + this.bestValueText = null; + this.bgFill = null; + this.boardGfx = null; + + this.boardX = 0; + this.boardY = 0; + this.cellSize = 0; + this.boardSize = 0; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) {} + + this.schemes = this.cache.json.get('colored-playfields')?.schemes ?? []; + this.best = Number(localStorage.getItem('2048-best') ?? 0); + + const HUD_H = 130; + const PAD = 60; + const availW = GAME_WIDTH - PAD * 2; + const availH = GAME_HEIGHT - HUD_H - PAD * 2; + this.boardSize = Math.min(Math.floor(availW * 0.48), availH); + this.cellSize = Math.floor((this.boardSize - 24) / 4); + this.boardX = Math.floor(GAME_WIDTH / 2 - this.boardSize / 2); + this.boardY = HUD_H + PAD; + + this.bgFill = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg); + + this._buildHUD(); + this._buildTileGrid(); + this._registerInput(); + + const savedId = localStorage.getItem('2048-scheme'); + if (savedId) { + this.selectedScheme = this.schemes.find(s => s.id === savedId) ?? this.schemes[0]; + this._startGameplay(); + } else { + this._showSchemePicker(); + } + } + + // ─── Layout helper ─────────────────────────────────────────────────────────── + + _cellCenter(r, c) { + const cellOuter = this.boardSize / 4; + return { + cx: this.boardX + c * cellOuter + cellOuter / 2, + cy: this.boardY + r * cellOuter + cellOuter / 2, + }; + } + + // ─── HUD ───────────────────────────────────────────────────────────────────── + + _buildHUD() { + const cx = GAME_WIDTH / 2; + + this.add.text(80, 65, '2048', { + fontFamily: FONT_TITLE, fontSize: '80px', color: COLORS.goldHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + + // Score box + this._drawScoreBox(cx - 200, 65, 'SCORE'); + this.scoreValueText = this.add.text(cx - 200, 78, '0', { + fontFamily: FONT_TITLE, fontSize: '44px', color: COLORS.textHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + + // Best box + this._drawScoreBox(cx + 200, 65, 'BEST'); + this.bestValueText = this.add.text(cx + 200, 78, String(this.best), { + fontFamily: FONT_TITLE, fontSize: '44px', color: COLORS.textHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + + new Button(this, GAME_WIDTH - 270, 65, 'New Game', () => this._newGame(), { variant: 'ghost', width: 200, height: 54, fontSize: 22 }).setDepth(D.ui); + new Button(this, GAME_WIDTH - 60, 65, 'Theme', () => this._showSchemePicker(), { variant: 'ghost', width: 110, height: 54, fontSize: 22 }).setDepth(D.ui); + } + + _drawScoreBox(x, y, label) { + const g = this.add.graphics().setDepth(D.ui); + g.fillStyle(0x000000, 0.3); + g.fillRoundedRect(x - 115, y - 55, 230, 100, 10); + this.add.text(x, y - 30, label, { + fontFamily: FONT_LABEL, fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + } + + // ─── Tile grid (built once, reused across games) ────────────────────────────── + + _buildTileGrid() { + const GAP = 8; + const W = this.boardSize + GAP * 2; + const H = this.boardSize + GAP * 2; + const bx = this.boardX - GAP; + const by = this.boardY - GAP; + + this.boardGfx = this.add.graphics().setDepth(D.board); + this._redrawBoardChrome(0x000000); + + // Empty cell slots (drawn once) + const slotGfx = this.add.graphics().setDepth(D.board + 1); + const cellOuter = this.boardSize / 4; + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const { cx, cy } = this._cellCenter(r, c); + const ts = this.cellSize - 4; + slotGfx.fillStyle(0xffffff, 0.06); + slotGfx.fillRoundedRect(cx - ts / 2, cy - ts / 2, ts, ts, 7); + } + } + + this.tileContainers = []; + this.tileGraphics = []; + this.tileTexts = []; + + const fontSize = `${Math.floor(this.cellSize * 0.36)}px`; + + for (let r = 0; r < 4; r++) { + this.tileContainers[r] = []; + this.tileGraphics[r] = []; + this.tileTexts[r] = []; + for (let c = 0; c < 4; c++) { + const { cx, cy } = this._cellCenter(r, c); + const g = this.add.graphics().setDepth(D.tiles); + const t = this.add.text(0, 0, '', { + fontFamily: FONT_TITLE, fontSize, color: '#ffffff', + }).setOrigin(0.5).setDepth(D.tileText); + const cont = this.add.container(cx, cy, [g, t]).setDepth(D.tiles).setAlpha(0); + this.tileContainers[r][c] = cont; + this.tileGraphics[r][c] = g; + this.tileTexts[r][c] = t; + } + } + } + + _redrawBoardChrome(baseColor) { + const GAP = 8; + const bx = this.boardX - GAP; + const by = this.boardY - GAP; + const W = this.boardSize + GAP * 2; + const H = this.boardSize + GAP * 2; + + this.boardGfx.clear(); + // Outer shadow + this.boardGfx.fillStyle(0x000000, 0.5); + this.boardGfx.fillRoundedRect(bx + 4, by + 4, W, H, 14); + // Board panel — darkened base color + const panelColor = this._mixInts(baseColor, 0xffffff, 0.12); + this.boardGfx.fillStyle(panelColor, 1); + this.boardGfx.fillRoundedRect(bx, by, W, H, 12); + // Inner border glow + this.boardGfx.lineStyle(2, this._mixInts(baseColor, 0xffffff, 0.3), 0.4); + this.boardGfx.strokeRoundedRect(bx, by, W, H, 12); + } + + _mixInts(a, b, t) { + const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255; + const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255; + return (Math.round(ar + (br - ar) * t) << 16) | + (Math.round(ag + (bg - ag) * t) << 8) | + Math.round(ab + (bb - ab) * t); + } + + // ─── Input ─────────────────────────────────────────────────────────────────── + + _registerInput() { + const dirMap = { + ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down', + KeyA: 'left', KeyD: 'right', KeyW: 'up', KeyS: 'down', + }; + this.input.keyboard.on('keydown', (e) => { + if (this.busy || this.gameOver || !this.state || this.pickerObjs.length > 0) return; + const dir = dirMap[e.code]; + if (dir) { e.preventDefault?.(); this._doMove(dir); } + }); + + let touchStart = null; + this.input.on('pointerdown', (p) => { touchStart = { x: p.x, y: p.y }; }); + this.input.on('pointerup', (p) => { + if (!touchStart || this.busy || this.gameOver || !this.state || this.pickerObjs.length > 0) return; + const dx = p.x - touchStart.x; + const dy = p.y - touchStart.y; + touchStart = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) return; + const dir = Math.abs(dx) > Math.abs(dy) + ? (dx > 0 ? 'right' : 'left') + : (dy > 0 ? 'down' : 'up'); + this._doMove(dir); + }); + } + + // ─── Scheme picker ─────────────────────────────────────────────────────────── + + _showSchemePicker() { + this._destroyPicker(); + + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const PW = 1260, PH = 740; + const px = cx - PW / 2, py = cy - PH / 2; + + // Dim overlay + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72) + .setDepth(D.picker).setInteractive(); + this.pickerObjs.push(dim); + + // Panel + const panel = this.add.graphics().setDepth(D.picker); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(px, py, PW, PH, 18); + panel.lineStyle(2, COLORS.accent, 0.8); + panel.strokeRoundedRect(px, py, PW, PH, 18); + this.pickerObjs.push(panel); + + const title = this.add.text(cx, py + 44, 'Choose a Theme', { + fontFamily: FONT_TITLE, fontSize: '46px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.picker + 1); + this.pickerObjs.push(title); + + // Swatches — 5 cols × 4 rows + const COLS = 5; + const SW = 218, SH = 122, SGAP = 16; + const gridW = COLS * SW + (COLS - 1) * SGAP; + const startX = cx - gridW / 2; + const startY = py + 106; + + this.schemes.forEach((scheme, idx) => { + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const sx = startX + col * (SW + SGAP); + const sy = startY + row * (SH + SGAP); + const scx = sx + SW / 2, scy = sy + SH / 2; + + const g = this.add.graphics().setDepth(D.picker + 1); + this._drawSwatch(g, sx, sy, SW, SH, scheme); + + // Selection ring + const isSelected = this.selectedScheme?.id === scheme.id; + const ring = this.add.graphics().setDepth(D.picker + 2); + if (isSelected) { + ring.lineStyle(3, COLORS.accent, 1); + ring.strokeRoundedRect(sx - 2, sy - 2, SW + 4, SH + 4, 10); + } + + // Scheme name + const nameText = this.add.text(scx, sy + SH - 18, scheme.name, { + fontFamily: FONT_LABEL, fontSize: '16px', color: '#ffffff', + stroke: '#000000', strokeThickness: 3, + }).setOrigin(0.5, 1).setDepth(D.picker + 3); + + // Hit area + const hitZone = this.add.rectangle(scx, scy, SW, SH, 0, 0) + .setDepth(D.picker + 4).setInteractive({ useHandCursor: true }); + + hitZone.on('pointerover', () => { + ring.clear(); + ring.lineStyle(3, COLORS.accent, 1); + ring.strokeRoundedRect(sx - 2, sy - 2, SW + 4, SH + 4, 10); + }); + hitZone.on('pointerout', () => { + ring.clear(); + if (this.selectedScheme?.id === scheme.id) { + ring.lineStyle(3, COLORS.accent, 1); + ring.strokeRoundedRect(sx - 2, sy - 2, SW + 4, SH + 4, 10); + } + }); + hitZone.on('pointerup', () => this._selectScheme(scheme)); + + this.pickerObjs.push(g, ring, nameText, hitZone); + }); + + // Close button (only if a game is in progress) + if (this.state !== null) { + const closeBtn = new Button(this, cx, py + PH - 36, 'Close', () => this._destroyPicker(), { + variant: 'ghost', width: 160, height: 48, fontSize: 20, + }).setDepth(D.picker + 4); + this.pickerObjs.push(closeBtn); + } + } + + _drawSwatch(g, sx, sy, sw, sh, scheme) { + const base = parseInt(scheme.base.replace('#', ''), 16); + g.fillStyle(base, 1); + g.fillRoundedRect(sx, sy, sw, sh, 8); + + // Diagonal accent sweeps + const accents = scheme.accents.map(h => parseInt(h.replace('#', ''), 16)); + accents.forEach((color, i) => { + g.fillStyle(color, 0.28 - i * 0.04); + const offset = i * 14; + // Simple diagonal stripe effect using a rotated rect approximation + g.fillTriangle( + sx + offset, sy, + sx + sw * 0.7 + offset, sy, + sx + offset, sy + sh * 0.8 + ); + }); + + // Tile preview squares + const tileColors = buildTilePalette(scheme); + const tileSize = 18; + const tileGap = 4; + const previewW = 4 * tileSize + 3 * tileGap; + const ptx = sx + (sw - previewW) / 2; + const pty = sy + sh / 2 - tileSize * 0.5 - 10; + [0, 2, 5, 8].forEach((lvl, i) => { + g.fillStyle(tileColors[lvl], 1); + g.fillRoundedRect(ptx + i * (tileSize + tileGap), pty, tileSize, tileSize, 3); + }); + } + + _selectScheme(scheme) { + this.selectedScheme = scheme; + localStorage.setItem('2048-scheme', scheme.id); + this._destroyPicker(); + this._clearOverlays(); + this._startGameplay(); + } + + _destroyPicker() { + for (const obj of this.pickerObjs) { + try { obj.destroy(); } catch (_) {} + } + this.pickerObjs = []; + } + + // ─── Gameplay ──────────────────────────────────────────────────────────────── + + _startGameplay() { + const scheme = this.selectedScheme; + this.tilePalette = buildTilePalette(scheme); + this.tileTextColors = buildTextColorTable(this.tilePalette); + + const baseInt = parseInt(scheme.base.replace('#', ''), 16); + this.bgFill.setFillStyle(baseInt); + this._redrawBoardChrome(baseInt); + + this.state = spawnTile(spawnTile(createState())); + this.score = 0; + this.gameOver = false; + this.wonAlready = false; + this.busy = false; + + this.scoreValueText.setText('0').setColor(COLORS.textHex); + + // Hide all tiles + for (let r = 0; r < 4; r++) + for (let c = 0; c < 4; c++) + this.tileContainers[r][c].setAlpha(0).setScale(1); + + // Animate the two starting tiles + const spawns = []; + for (let i = 0; i < 16; i++) if (this.state.grid[i] > 0) spawns.push(i); + this._redrawAllTiles(this.state); + for (const idx of spawns) { + const r = Math.floor(idx / 4), c = idx % 4; + const cont = this.tileContainers[r][c]; + cont.setAlpha(1).setScale(0); + this.tweens.add({ targets: cont, scaleX: 1, scaleY: 1, duration: 160, ease: 'Back.easeOut' }); + } + } + + _newGame() { + this._clearOverlays(); + this._startGameplay(); + } + + // ─── Move logic ────────────────────────────────────────────────────────────── + + _doMove(dir) { + if (this.busy || this.gameOver || !this.state) return; + const newState = applyMove(this.state, dir); + if (!newState) return; + + this.busy = true; + playSound(this, SFX.PIECE_CLICK); + + this._animateMove(this.state, newState, () => { + this.state = newState; + this._updateScore(newState.score); + + if (!this.wonAlready && newState.won) { + this.wonAlready = true; + this.time.delayedCall(120, () => this._showWinOverlay()); + } + + if (!hasValidMoves(newState)) { + this.gameOver = true; + this.busy = false; + this.time.delayedCall(300, () => this._showGameOverOverlay()); + return; + } + + this.busy = false; + }); + } + + _animateMove(oldState, newState, onDone) { + const SLIDE_MS = 95; + const MERGE_MS = 140; + const SPAWN_MS = 115; + + // Phase 1: slide tiles to their new positions + const movesToAnimate = newState.moves.filter(m => m.fromIdx !== m.toIdx); + + // For merges, two source tiles go to the same destination. + // We only animate the "first" of a pair (visually one tile slides). + const seen = new Set(); + const uniqueMoves = []; + for (const m of movesToAnimate) { + const key = `${m.fromIdx}-${m.toIdx}`; + if (!seen.has(key)) { seen.add(key); uniqueMoves.push(m); } + } + + const spawnPhase = () => { + if (newState.spawned === null) { onDone(); return; } + const r = Math.floor(newState.spawned / 4), c = newState.spawned % 4; + const cont = this.tileContainers[r][c]; + cont.setAlpha(1).setScale(0); + this.tweens.add({ + targets: cont, scaleX: 1, scaleY: 1, + duration: SPAWN_MS, ease: 'Back.easeOut', + onComplete: onDone, + }); + }; + + const afterSlides = () => { + this._redrawAllTiles(newState); + + if (newState.mergedAt.length === 0) { spawnPhase(); return; } + + let mergePending = newState.mergedAt.length; + const afterMerges = () => { mergePending--; if (mergePending === 0) spawnPhase(); }; + + for (const idx of newState.mergedAt) { + const r = Math.floor(idx / 4), c = idx % 4; + const cont = this.tileContainers[r][c]; + const mergeVal = newState.grid[idx]; + this._scorePopup(cont.x, cont.y - 40, mergeVal); + this.tweens.add({ + targets: cont, scaleX: 1.2, scaleY: 1.2, + duration: MERGE_MS / 2, ease: 'Quad.easeOut', + yoyo: true, onComplete: afterMerges, + }); + } + }; + + let pending = uniqueMoves.length; + if (pending === 0) { afterSlides(); return; } + + for (const mv of uniqueMoves) { + const fr = Math.floor(mv.fromIdx / 4), fc = mv.fromIdx % 4; + const tr = Math.floor(mv.toIdx / 4), tc = mv.toIdx % 4; + const cont = this.tileContainers[fr][fc]; + const { cx: tx, cy: ty } = this._cellCenter(tr, tc); + this.tweens.add({ + targets: cont, x: tx, y: ty, + duration: SLIDE_MS, ease: 'Quad.easeOut', + onComplete: () => { pending--; if (pending === 0) afterSlides(); }, + }); + } + } + + _redrawAllTiles(state) { + const CS = this.cellSize - 6; + const baseLevel = 36; + + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const val = state.grid[r * 4 + c]; + const { cx, cy } = this._cellCenter(r, c); + const cont = this.tileContainers[r][c]; + const gfx = this.tileGraphics[r][c]; + const txt = this.tileTexts[r][c]; + + if (val === 0) { + cont.setAlpha(0); + cont.setPosition(cx, cy).setScale(1); + continue; + } + cont.setPosition(cx, cy).setScale(1); + + const level = getTileLevel(val); + const color = this.tilePalette[level]; + + gfx.clear(); + // Shadow + gfx.fillStyle(0x000000, 0.35); + gfx.fillRoundedRect(-CS / 2 + 3, -CS / 2 + 3, CS, CS, 8); + // Main tile + gfx.fillStyle(color, 1); + gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, CS, 8); + // Top sheen + const sheen = this._mixInts(color, 0xffffff, 0.22); + gfx.fillStyle(sheen, 0.3); + gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, Math.floor(CS * 0.45), 8); + + txt.setText(String(val)); + txt.setColor(this.tileTextColors[level]); + const log = Math.round(Math.log2(val)); + const fsMultiplier = (FS_LEVELS[log] ?? 18) / 100; + txt.setFontSize(`${Math.floor(this.cellSize * fsMultiplier)}px`); + + cont.setAlpha(1); + } + } + } + + // ─── Score ─────────────────────────────────────────────────────────────────── + + _updateScore(newScore) { + this.score = newScore; + this.scoreValueText.setText(newScore.toLocaleString()); + if (newScore > this.best) { + this.best = newScore; + localStorage.setItem('2048-best', String(newScore)); + this.bestValueText.setText(newScore.toLocaleString()).setColor(COLORS.goldHex); + } + } + + _scorePopup(x, y, points) { + const t = this.add.text(x, y, `+${points.toLocaleString()}`, { + fontFamily: FONT_TITLE, fontSize: '30px', color: COLORS.goldHex, + stroke: '#0a0805', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.fx); + this.tweens.add({ + targets: t, y: y - 68, alpha: 0, duration: 820, ease: 'Quad.easeOut', + onComplete: () => t.destroy(), + }); + } + + // ─── Win overlay ───────────────────────────────────────────────────────────── + + _showWinOverlay() { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const accentInt = this.tilePalette[this.tilePalette.length - 1]; + + // Particle celebration + if (!this.textures.exists('2048-dot')) { + const g = this.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0xffffff, 1); + g.fillCircle(5, 5, 5); + g.generateTexture('2048-dot', 10, 10); + g.destroy(); + } + const emitter = this.add.particles(cx, cy - 60, '2048-dot', { + speed: { min: 250, max: 680 }, + lifespan: 1800, + scale: { start: 1.6, end: 0 }, + alpha: { start: 1, end: 0 }, + quantity: 5, + frequency: 18, + tint: [accentInt, 0xffffff, this.tilePalette[8]], + angle: { min: 0, max: 360 }, + }).setDepth(D.fx); + this.time.delayedCall(2200, () => { try { emitter.destroy(); } catch (_) {} }); + + const objs = []; + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.5) + .setDepth(D.overlay).setInteractive(); + objs.push(dim); + + const panel = this.add.graphics().setDepth(D.overlay + 1); + panel.fillStyle(COLORS.panel, 0.96); + panel.fillRoundedRect(cx - 400, cy - 195, 800, 390, 22); + panel.lineStyle(3, accentInt, 1); + panel.strokeRoundedRect(cx - 400, cy - 195, 800, 390, 22); + objs.push(panel); + + const accentHex = '#' + accentInt.toString(16).padStart(6, '0'); + objs.push(this.add.text(cx, cy - 110, '🎉 You reached 2048!', { + fontFamily: FONT_TITLE, fontSize: '58px', color: accentHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + objs.push(this.add.text(cx, cy - 20, 'Keep going for an even higher score!', { + fontFamily: FONT_LABEL, fontSize: '28px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + const closeAll = () => { objs.forEach(o => { try { o.destroy(); } catch (_) {} }); }; + + const btn = new Button(this, cx, cy + 90, 'Keep Playing', closeAll, { width: 270, height: 62, fontSize: 26 }) + .setDepth(D.overlay + 2); + objs.push(btn); + + this.time.delayedCall(5000, closeAll); + this.overlayObjs.push(...objs); + } + + // ─── Game over overlay ─────────────────────────────────────────────────────── + + _showGameOverOverlay() { + api.post('/history/single-player', { + slug: '2048', score: this.score, opponentScores: [], result: 'win', + }).catch(() => {}); + + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const objs = []; + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7) + .setDepth(D.overlay).setInteractive(); + objs.push(dim); + + const panel = this.add.graphics().setDepth(D.overlay + 1); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 420, cy - 305, 840, 610, 24); + panel.lineStyle(3, COLORS.accent, 0.9); + panel.strokeRoundedRect(cx - 420, cy - 305, 840, 610, 24); + objs.push(panel); + + objs.push(this.add.text(cx, cy - 218, 'Game Over', { + fontFamily: FONT_TITLE, fontSize: '72px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + objs.push(this.add.text(cx, cy - 120, 'FINAL SCORE', { + fontFamily: FONT_LABEL, fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + objs.push(this.add.text(cx, cy - 36, this.score.toLocaleString(), { + fontFamily: FONT_TITLE, fontSize: '92px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + const isNewBest = this.score >= this.best; + const bestMsg = isNewBest ? '★ New Best!' : `Best: ${this.best.toLocaleString()}`; + objs.push(this.add.text(cx, cy + 72, bestMsg, { + fontFamily: FONT_LABEL, fontSize: '32px', + color: isNewBest ? COLORS.goldHex : COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlay + 2)); + + const playAgain = new Button(this, cx - 130, cy + 186, 'Play Again', () => this._newGame(), { + width: 220, height: 62, fontSize: 24, + }).setDepth(D.overlay + 2); + const leave = new Button(this, cx + 130, cy + 186, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 220, height: 62, fontSize: 24, + }).setDepth(D.overlay + 2); + objs.push(playAgain, leave); + this.overlayObjs.push(...objs); + } + + _clearOverlays() { + for (const obj of this.overlayObjs) { + try { obj.destroy(); } catch (_) {} + } + this.overlayObjs = []; + } +} diff --git a/public/src/games/2048/2048Logic.js b/public/src/games/2048/2048Logic.js new file mode 100644 index 0000000..f319e3e --- /dev/null +++ b/public/src/games/2048/2048Logic.js @@ -0,0 +1,189 @@ +function hexToInt(hex) { + return parseInt(hex.replace('#', ''), 16); +} + +function mixInt(a, b, t) { + const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255; + const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255; + return (Math.round(ar + (br - ar) * t) << 16) | + (Math.round(ag + (bg - ag) * t) << 8) | + Math.round(ab + (bb - ab) * t); +} + +// Returns 11 integer colors for tile values 2, 4, 8 … 2048 +export function buildTilePalette(scheme) { + const accInts = scheme.accents.map(hexToInt); + const ramp = [ + mixInt(accInts[0], 0x000000, 0.55), + ...accInts, + mixInt(accInts[accInts.length - 1], 0xffffff, 0.35), + ]; + const palette = []; + for (let i = 0; i < 11; i++) { + const t = i / 10; + const seg = (ramp.length - 1) * t; + const lo = Math.floor(seg); + const hi = Math.min(lo + 1, ramp.length - 1); + palette.push(mixInt(ramp[lo], ramp[hi], seg - lo)); + } + return palette; +} + +// Returns 11 hex color strings for tile text (light on dark, dark on light) +export function buildTextColorTable(palette) { + return palette.map((_, i) => (i >= 5 ? '#ffffff' : '#1a1208')); +} + +// Map tile value (2..2048+) to palette index 0-10 +export function getTileLevel(value) { + if (value <= 0) return 0; + return Math.min(Math.round(Math.log2(value)) - 1, 10); +} + +export function createState() { + return { + grid: new Int32Array(16), + score: 0, + won: false, + over: false, + spawned: null, + mergedAt: [], + moves: [], + }; +} + +export function spawnTile(state) { + const empties = []; + for (let i = 0; i < 16; i++) if (state.grid[i] === 0) empties.push(i); + if (empties.length === 0) return { ...state, spawned: null, mergedAt: [], moves: [] }; + const idx = empties[Math.floor(Math.random() * empties.length)]; + const val = Math.random() < 0.9 ? 2 : 4; + const grid = new Int32Array(state.grid); + grid[idx] = val; + return { ...state, grid, spawned: idx, mergedAt: [], moves: [] }; +} + +export function hasValidMoves(state) { + for (let i = 0; i < 16; i++) { + if (state.grid[i] === 0) return true; + const r = Math.floor(i / 4), c = i % 4; + if (c < 3 && state.grid[i] === state.grid[i + 1]) return true; + if (r < 3 && state.grid[i] === state.grid[i + 4]) return true; + } + return false; +} + +// Rotate grid 90° clockwise (for direction abstraction) +function rotateGrid(grid) { + const g = new Int32Array(16); + for (let r = 0; r < 4; r++) + for (let c = 0; c < 4; c++) + g[c * 4 + (3 - r)] = grid[r * 4 + c]; + return g; +} + +// Merge a single row leftward; returns { row, mergedCols, scoreGained } +function mergeRow(row) { + const vals = row.filter(v => v > 0); + const out = [0, 0, 0, 0]; + const mergedCols = []; + let si = 0; + for (let i = 0; i < vals.length; i++) { + if (i + 1 < vals.length && vals[i] === vals[i + 1]) { + out[si] = vals[i] * 2; + mergedCols.push(si); + si++; + i++; + } else { + out[si++] = vals[i]; + } + } + return { row: out, mergedCols }; +} + +export function applyMove(state, dir) { + // Number of 90° CW rotations to make the move direction equivalent to "left" + const rotations = { left: 0, down: 1, right: 2, up: 3 }; + const nRot = rotations[dir]; + + let g = new Int32Array(state.grid); + for (let i = 0; i < nRot; i++) g = rotateGrid(g); + + const newGrid = new Int32Array(16); + const mergedAtRotated = []; + let scoreGained = 0; + // moves: track fromIdx→toIdx in rotated space, then we'll map back + const movesRotated = []; + + for (let r = 0; r < 4; r++) { + const row = [g[r*4], g[r*4+1], g[r*4+2], g[r*4+3]]; + const { row: merged, mergedCols } = mergeRow(row); + + for (const mc of mergedCols) { + mergedAtRotated.push(r * 4 + mc); + scoreGained += merged[mc]; + } + + // Track where each non-zero original cell ends up + const srcCols = []; + for (let c = 0; c < 4; c++) if (row[c] > 0) srcCols.push(c); + let dstIdx = 0; + let srcConsumed = 0; + for (let di = 0; di < 4 && dstIdx < srcCols.length; di++) { + if (merged[di] === 0) continue; + if (merged[di] === row[srcCols[srcConsumed]] && srcConsumed + 1 < srcCols.length && row[srcCols[srcConsumed]] === row[srcCols[srcConsumed + 1]]) { + // two tiles merged into merged[di] + movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed], toIdx: r*4+di, value: merged[di] }); + movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed+1], toIdx: r*4+di, value: merged[di] }); + srcConsumed += 2; + } else { + movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed], toIdx: r*4+di, value: merged[di] }); + srcConsumed++; + } + newGrid[r*4+di] = merged[di]; + } + } + + // Check if anything changed + let changed = false; + for (let i = 0; i < 16; i++) if (newGrid[i] !== g[i]) { changed = true; break; } + if (!changed) return null; + + // Rotate result back + const unRotations = (4 - nRot) % 4; + + let finalGrid = newGrid; + for (let i = 0; i < unRotations; i++) finalGrid = rotateGrid(finalGrid); + + // Map mergedAt and moves back through unRotations + function mapIdx(idx, times) { + let r = Math.floor(idx / 4), c = idx % 4; + for (let i = 0; i < times; i++) { + const nr = c, nc = 3 - r; + r = nr; c = nc; + } + return r * 4 + c; + } + + const mergedAt = mergedAtRotated.map(i => mapIdx(i, unRotations)); + const moves = movesRotated.map(m => ({ + fromIdx: mapIdx(m.fromIdx, unRotations), + toIdx: mapIdx(m.toIdx, unRotations), + value: m.value, + })); + + const newScore = state.score + scoreGained; + const won = state.won || finalGrid.some(v => v >= 2048); + + const nextState = { + grid: finalGrid, + score: newScore, + won, + over: false, + spawned: null, + mergedAt, + moves, + }; + + return spawnTile(nextState); +} diff --git a/public/src/main.js b/public/src/main.js index 4bd0015..c25e863 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -76,6 +76,7 @@ import SlotsGame from './games/slots/SlotsGame.js'; import CribbageGame from './games/cribbage/CribbageGame.js'; import CanastaGame from './games/canasta/CanastaGame.js'; import DotLinkGame from './games/dotlink/DotLinkGame.js'; +import Game2048 from './games/2048/2048Game.js'; const config = { type: Phaser.AUTO, @@ -165,6 +166,7 @@ const config = { CribbageGame, CanastaGame, DotLinkGame, + Game2048, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index d6ee0de..3f91b24 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index e36ef71..ef4af5b 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -47,7 +47,7 @@ registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3, hasTutorial: true, iconFrame: 19 }); registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 20 }); registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 21 }); -registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 22 }); +registerGame({ slug: 'wordle', name: 'Wordle Race', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 22 }); registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 23 }); registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 24 }); registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1, iconFrame: 25 }); @@ -91,3 +91,4 @@ registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlay registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 64 }); registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 }); registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 }); +registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 });