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 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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue