feat: add 2048 single-player puzzle game
- Implement complete 2048 game with Phaser.js scene and game logic - Add customizable theme picker with multiple color schemes - Include smooth tile slide, merge, and spawn animations - Add sound effects (scifi-plink, scifi-plonk) and particle effects - Track best score in localStorage - Register game in server registry with icon frame 67 - Update Wordle name to "Wordle Race"
This commit is contained in:
parent
2b224ff62b
commit
24678602f4
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 256 KiB |
Binary file not shown.
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,7 @@ import SlotsGame from './games/slots/SlotsGame.js';
|
||||||
import CribbageGame from './games/cribbage/CribbageGame.js';
|
import CribbageGame from './games/cribbage/CribbageGame.js';
|
||||||
import CanastaGame from './games/canasta/CanastaGame.js';
|
import CanastaGame from './games/canasta/CanastaGame.js';
|
||||||
import DotLinkGame from './games/dotlink/DotLinkGame.js';
|
import DotLinkGame from './games/dotlink/DotLinkGame.js';
|
||||||
|
import Game2048 from './games/2048/2048Game.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -165,6 +166,7 @@ const config = {
|
||||||
CribbageGame,
|
CribbageGame,
|
||||||
CanastaGame,
|
CanastaGame,
|
||||||
DotLinkGame,
|
DotLinkGame,
|
||||||
|
Game2048,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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: '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: '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 });
|
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: '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: '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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue