feat: add Hexsweeper logic game

- Register Hexsweeper game in server registry with icon frame 52
- Add HexsweeperGame import and scene configuration in main.js
- Add hexsweeper slug mapping in GameRoomScene.js
- Update game-icons sprite sheet with new icon
This commit is contained in:
Brian Fertig 2026-06-08 19:09:40 -06:00
parent c01027e6c5
commit 147ef4b89b
7 changed files with 643 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

View File

@ -0,0 +1,438 @@
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 {
DIFFICULTIES, DIFFICULTY_ORDER, newGame, reveal, toggleFlag, chord, minesRemaining,
} from './HexsweeperLogic.js';
// Scene / board theme — a cool slate felt to read as a classic Minesweeper grid.
const FELT = 0x12202b;
const HIDDEN = 0x2c6e8f; // raised, un-revealed hex
const HIDDEN_HI = 0x3d8bb0; // hover
const REVEALED = 0x18313f; // recessed, revealed hex
const REVEAL_LN = 0x0c1a23;
const FLAG_RED = '#ff5252';
const MINE_HEX = 0x1c3a49;
const BOOM_HEX = 0xc0392b;
const WRONG_HEX = 0x6b3b3b;
// Classic adjacency palette, brightened for the dark felt (counts run 1..6 on hex).
const NUM_COLORS = ['', '#5b9bff', '#45d17a', '#ff6b6b', '#c792ff', '#ffb454', '#4ec1c1'];
const D = { felt: -2, hexBase: 0, glyph: 1, ui: 30, overlay: 60, overlayUI: 62 };
const ROOT3 = Math.sqrt(3);
export default class HexsweeperGame extends Phaser.Scene {
constructor() { super('HexsweeperGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'hexsweeper', name: 'Hexsweeper' };
this.view = 'select';
this.g = null; // logic state
this.difficulty = 'easy';
this.flagMode = false;
this.elapsed = 0;
this.timerEvent = null;
this.overlayUp = false;
this.cellGfx = [];
this.cellLabel = [];
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
// Right-click should flag, not open the browser context menu.
if (this.input.mouse) this.input.mouse.disableContextMenu();
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
this.layer = this.add.container(0, 0);
this.showDifficultySelect();
}
clearLayer() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
this.layer.removeAll(true);
this.cellGfx = [];
this.cellLabel = [];
this.minesText = null;
this.timerText = null;
this.flagBtn = null;
}
// ── Difficulty select ─────────────────────────────────────────────────────────
showDifficultySelect() {
this.view = 'select';
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 150, 'HEXSWEEPER', {
fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 224, 'Clear every hex without striking a mine. Each cell counts the mines among its six neighbours.', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const pick = this.add.text(cx, 312, 'Choose a difficulty', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([title, sub, pick]);
const TILE_W = 360;
const TILE_H = 220;
const GAP = 40;
const totalW = DIFFICULTY_ORDER.length * TILE_W + (DIFFICULTY_ORDER.length - 1) * GAP;
const left = cx - totalW / 2 + TILE_W / 2;
const y = 540;
const accents = { easy: 0x45d17a, medium: 0x5b9bff, hard: 0xffb454, legendary: 0xff5252 };
DIFFICULTY_ORDER.forEach((key, i) => {
const def = DIFFICULTIES[key];
const x = left + i * (TILE_W + GAP);
const stroke = accents[key];
const tile = this.add.rectangle(x, y, TILE_W, TILE_H, 0x16303d).setStrokeStyle(3, stroke, 1);
const name = this.add.text(x, y - 64, def.label, {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5);
const dims = this.add.text(x, y + 6, `${def.cols} × ${def.rows} grid`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const mines = this.add.text(x, y + 56, `${def.mines} mines`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add([tile, name, dims, mines]);
tile.setInteractive({ useHandCursor: true });
tile.on('pointerover', () => tile.setStrokeStyle(5, stroke, 1));
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
tile.on('pointerup', () => this.startGame(key));
});
const back = new Button(this, cx, GAME_HEIGHT - 110, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, height: 60, fontSize: 24 });
this.layer.add(back);
const tip = this.add.text(cx, GAME_HEIGHT - 200, 'Left-click reveals · Right-click flags · Click a satisfied number to chord', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add(tip);
}
// ── Start / restart a board ───────────────────────────────────────────────────
startGame(difficulty) {
this.view = 'play';
this.difficulty = difficulty;
this.g = newGame(difficulty);
this.flagMode = false;
this.elapsed = 0;
this.overlayUp = false;
this.clearLayer();
this.computeLayout();
this.buildBoard();
this.drawHud();
this.renderAll();
}
// Fit the board into the area right of the button strip, scaling the hex size
// so even Legendary (20×16) lands fully on screen.
computeLayout() {
const g = this.g;
const LEFT_STRIP = 300;
const TOP = 150;
const BOTTOM = GAME_HEIGHT - 50;
const RIGHT = GAME_WIDTH - 50;
const availW = RIGHT - LEFT_STRIP;
const availH = BOTTOM - TOP;
// width = (cols + 0.5) * (√3 · size); height = (1.5·rows + 0.5) · size
const sizeW = availW / ((g.cols + 0.5) * ROOT3);
const sizeH = availH / (1.5 * g.rows + 0.5);
this.size = Math.min(sizeW, sizeH, 58);
this.w = ROOT3 * this.size; // hex width / column spacing
this.rowStep = 1.5 * this.size; // vertical spacing
const regionCx = (LEFT_STRIP + RIGHT) / 2;
const regionCy = (TOP + BOTTOM) / 2;
this.originX = regionCx - (g.cols - 1) * this.w / 2;
this.originY = regionCy - (g.rows - 1) * this.rowStep / 2;
}
hexCenter(col, row) {
return {
x: this.originX + col * this.w + ((row & 1) ? this.w / 2 : 0),
y: this.originY + row * this.rowStep,
};
}
hexPointsLocal() {
const pts = [];
for (let i = 0; i < 6; i++) {
const ang = (Math.PI / 180) * (60 * i - 30); // pointy-top: vertices top & bottom
pts.push({ x: this.size * Math.cos(ang), y: this.size * Math.sin(ang) });
}
return pts;
}
buildBoard() {
const g = this.g;
const local = this.hexPointsLocal();
const flat = [];
for (const p of local) flat.push(p.x, p.y);
for (let r = 0; r < g.rows; r++) {
this.cellGfx[r] = [];
this.cellLabel[r] = [];
for (let c = 0; c < g.cols; c++) {
const { x, y } = this.hexCenter(c, r);
const gfx = this.add.graphics({ x, y }).setDepth(D.hexBase);
gfx.setInteractive(new Phaser.Geom.Polygon(flat), Phaser.Geom.Polygon.Contains);
gfx._col = c; gfx._row = r;
gfx.on('pointerover', () => { gfx._hover = true; this.drawCell(c, r); });
gfx.on('pointerout', () => { gfx._hover = false; this.drawCell(c, r); });
gfx.on('pointerdown', (pointer) => this.onCellDown(c, r, pointer));
this.layer.add(gfx);
this.cellGfx[r][c] = gfx;
const label = this.add.text(x, y, '', {
fontFamily: 'Righteous', fontSize: `${Math.round(this.size * 0.95)}px`, color: '#ffffff',
}).setOrigin(0.5).setDepth(D.glyph);
this.layer.add(label);
this.cellLabel[r][c] = label;
}
}
}
drawHud() {
const stripCx = 150;
const title = this.add.text(40, 70, 'HEXSWEEPER', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
const diff = this.add.text(40, 112, DIFFICULTIES[this.difficulty].label, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add([title, diff]);
this.minesText = this.add.text(GAME_WIDTH - 360, 88, '', {
fontFamily: 'Righteous', fontSize: '34px', color: FLAG_RED,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.timerText = this.add.text(GAME_WIDTH - 50, 88, '', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.layer.add([this.minesText, this.timerText]);
const BTN_W = 220;
const BTN_H = 58;
const BTN_GAP = 16;
const totalH = 3 * BTN_H + 2 * BTN_GAP;
let btnY = GAME_HEIGHT / 2 - totalH / 2;
this.flagBtn = new Button(this, stripCx, btnY, '🚩 Flag: Off', () => this.toggleFlagMode(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
btnY += BTN_H + BTN_GAP;
const restart = new Button(this, stripCx, btnY, 'New Game', () => this.startGame(this.difficulty),
{ width: BTN_W, height: BTN_H, fontSize: 22 });
btnY += BTN_H + BTN_GAP;
const diffBtn = new Button(this, stripCx, btnY, 'Difficulty', () => this.showDifficultySelect(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.layer.add([this.flagBtn, restart, diffBtn]);
this.updateHud();
}
updateHud() {
if (this.minesText) this.minesText.setText(`🚩 ${minesRemaining(this.g)}`);
if (this.timerText) {
const m = Math.floor(this.elapsed / 60);
const s = String(this.elapsed % 60).padStart(2, '0');
this.timerText.setText(`${m}:${s}`);
}
}
toggleFlagMode() {
this.flagMode = !this.flagMode;
if (this.flagBtn) {
this.flagBtn.setLabel(this.flagMode ? '🚩 Flag: On' : '🚩 Flag: Off').setActive(this.flagMode);
}
}
startTimer() {
if (this.timerEvent) return;
this.timerEvent = this.time.addEvent({
delay: 1000, loop: true,
callback: () => { this.elapsed++; this.updateHud(); },
});
}
stopTimer() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
}
// ── Input ─────────────────────────────────────────────────────────────────────
onCellDown(col, row, pointer) {
if (this.overlayUp || this.g.state === 'won' || this.g.state === 'lost') return;
const flag = (pointer && pointer.rightButtonDown && pointer.rightButtonDown()) || this.flagMode;
if (flag) {
if (toggleFlag(this.g, col, row)) {
playSound(this, SFX.PIECE_CLICK);
this.drawCell(col, row);
this.updateHud();
}
return;
}
const cell = this.g.board[row][col];
if (cell.flagged) return;
const wasReady = !this.g.firstClickDone;
let changed;
if (cell.revealed && cell.count > 0) {
changed = chord(this.g, col, row);
} else {
changed = reveal(this.g, col, row);
}
if (wasReady) this.startTimer();
if (this.g.state === 'lost') {
playSound(this, SFX.SCIFI_EXPLODE);
this.renderAll();
this.endGame(false);
return;
}
if (changed.length) playSound(this, changed.length > 1 ? SFX.CARD_DEAL : SFX.PIECE_CLICK);
for (const [c, r] of changed) this.drawCell(c, r);
this.updateHud();
if (this.g.state === 'won') {
playSound(this, SFX.VICTORY_SHORT);
this.endGame(true);
}
}
// ── Rendering ─────────────────────────────────────────────────────────────────
renderAll() {
for (let r = 0; r < this.g.rows; r++) {
for (let c = 0; c < this.g.cols; c++) this.drawCell(c, r);
}
}
drawCell(col, row) {
const cell = this.g.board[row][col];
const gfx = this.cellGfx[row][col];
const label = this.cellLabel[row][col];
const local = this.hexPointsLocal();
const over = this.g.state === 'won' || this.g.state === 'lost';
let fill = HIDDEN;
let stroke = 0x0d2330;
let strokeW = 2;
let glyph = '';
let glyphColor = '#ffffff';
if (cell.revealed) {
if (cell.mine) {
const isBoom = this.g.exploded && this.g.exploded[0] === col && this.g.exploded[1] === row;
fill = isBoom ? BOOM_HEX : MINE_HEX;
stroke = REVEAL_LN;
glyph = '✸';
glyphColor = isBoom ? '#ffffff' : '#ff8a8a';
} else {
fill = REVEALED;
stroke = REVEAL_LN;
if (cell.count > 0) { glyph = String(cell.count); glyphColor = NUM_COLORS[cell.count]; }
}
} else if (over && cell.mine && !cell.flagged) {
// Reveal remaining mines on game over.
fill = MINE_HEX; stroke = REVEAL_LN; glyph = '✸'; glyphColor = '#ff8a8a';
} else if (cell.flagged) {
if (over && !cell.mine) { fill = WRONG_HEX; glyph = '✗'; glyphColor = '#ffd5d5'; }
else { fill = gfx._hover && !over ? HIDDEN_HI : HIDDEN; glyph = '🚩'; glyphColor = FLAG_RED; }
} else {
fill = (gfx._hover && !over) ? HIDDEN_HI : HIDDEN;
}
gfx.clear();
gfx.fillStyle(fill, 1);
gfx.beginPath();
gfx.moveTo(local[0].x, local[0].y);
for (let i = 1; i < local.length; i++) gfx.lineTo(local[i].x, local[i].y);
gfx.closePath();
gfx.fillPath();
// Subtle top bevel highlight on raised (hidden) hexes.
if (!cell.revealed && !(over && cell.mine)) {
gfx.fillStyle(0xffffff, 0.10);
gfx.beginPath();
gfx.moveTo(local[5].x, local[5].y);
gfx.lineTo(local[0].x, local[0].y);
gfx.lineTo(local[1].x, local[1].y);
gfx.lineTo(local[1].x * 0.6, local[1].y * 0.6);
gfx.lineTo(local[5].x * 0.6, local[5].y * 0.6);
gfx.closePath();
gfx.fillPath();
}
gfx.lineStyle(strokeW, stroke, 1);
gfx.beginPath();
gfx.moveTo(local[0].x, local[0].y);
for (let i = 1; i < local.length; i++) gfx.lineTo(local[i].x, local[i].y);
gfx.closePath();
gfx.strokePath();
label.setText(glyph).setColor(glyphColor);
}
// ── End of game ───────────────────────────────────────────────────────────────
endGame(won) {
this.overlayUp = true;
this.stopTimer();
api.post('/history/single-player', {
slug: 'hexsweeper', score: this.elapsed, opponentScores: [], result: won ? 'win' : 'loss',
}).catch(() => { /* best effort */ });
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 200, 640, 400, 20);
panel.lineStyle(3, won ? 0x45d17a : COLORS.danger, 1);
panel.strokeRoundedRect(cx - 320, cy - 200, 640, 400, 20);
this.layer.add(panel);
const m = Math.floor(this.elapsed / 60);
const s = String(this.elapsed % 60).padStart(2, '0');
const title = this.add.text(cx, cy - 120, won ? 'Cleared!' : 'Boom!', {
fontFamily: 'Righteous', fontSize: '72px', color: won ? '#45d17a' : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const stat = this.add.text(cx, cy - 30,
won
? `You swept the ${DIFFICULTIES[this.difficulty].label} field in ${m}:${s}.`
: `You hit a mine after ${m}:${s}.`, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, stat]);
const again = new Button(this, cx - 170, cy + 110, 'New Game', () => this.startGame(this.difficulty),
{ width: 280, height: 60, fontSize: 26 }).setDepth(D.overlayUI);
const diff = new Button(this, cx + 170, cy + 110, 'Difficulty', () => this.showDifficultySelect(),
{ width: 280, height: 60, fontSize: 26, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([again, diff]);
}
}

View File

@ -0,0 +1,201 @@
// Hexsweeper — pure board model for a hexagonal Minesweeper. No Phaser, no DOM.
// Self-contained so it can be unit-tested in Node and reused by the scene.
//
// Layout: "odd-r" offset coordinates over pointy-top hexagons. A cell is (col, row)
// with col = 0 left .. cols-1 right, row = 0 top .. rows-1 bottom. Odd rows are
// shifted half a hex to the RIGHT, which gives every interior cell exactly six
// neighbours (so adjacency counts run 0..6, versus 0..8 for square Minesweeper).
//
// A cell: { mine, count, revealed, flagged }
// Mines are placed lazily on the first reveal so the opening click (and the ring
// around it) is always safe — classic Minesweeper behaviour.
export const DIFFICULTIES = {
easy: { key: 'easy', label: 'Easy', cols: 8, rows: 8, mines: 8 },
medium: { key: 'medium', label: 'Medium', cols: 12, rows: 10, mines: 20 },
hard: { key: 'hard', label: 'Hard', cols: 16, rows: 13, mines: 44 },
legendary: { key: 'legendary', label: 'Legendary', cols: 20, rows: 16, mines: 80 },
};
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
// Neighbour column/row deltas, indexed by row parity (0 = even, 1 = odd).
const NEIGHBOR_DELTAS = [
// even rows (no shift): the two cells above/below sit to the upper/lower LEFT
[[+1, 0], [-1, 0], [0, -1], [-1, -1], [0, +1], [-1, +1]],
// odd rows (shifted right): the two cells above/below sit to the upper/lower RIGHT
[[+1, 0], [-1, 0], [0, -1], [+1, -1], [0, +1], [+1, +1]],
];
export function inBounds(col, row, cols, rows) {
return col >= 0 && col < cols && row >= 0 && row < rows;
}
// Up to six in-bounds neighbours of (col, row) as [col, row] pairs.
export function neighbors(col, row, cols, rows) {
const out = [];
for (const [dc, dr] of NEIGHBOR_DELTAS[row & 1]) {
const nc = col + dc;
const nr = row + dr;
if (inBounds(nc, nr, cols, rows)) out.push([nc, nr]);
}
return out;
}
function makeCell() {
return { mine: false, count: 0, revealed: false, flagged: false };
}
// Fresh, fully-hidden board with no mines yet (placed on the first reveal).
export function newGame(difficulty) {
const def = DIFFICULTIES[difficulty] ?? DIFFICULTIES.easy;
const board = Array.from({ length: def.rows }, () =>
Array.from({ length: def.cols }, () => makeCell()));
return {
difficulty: def.key,
cols: def.cols,
rows: def.rows,
mines: def.mines,
board,
state: 'ready', // 'ready' | 'playing' | 'won' | 'lost'
flagsUsed: 0,
revealedCount: 0,
firstClickDone: false,
exploded: null, // [col, row] of the detonated mine, when lost
};
}
export function cellAt(g, col, row) {
return g.board[row][col];
}
export function minesRemaining(g) {
return g.mines - g.flagsUsed;
}
// Place `g.mines` mines uniformly at random, never on the safe pocket
// (the clicked cell and its neighbours), then compute every cell's count.
function placeMines(g, safeCol, safeRow) {
const safe = new Set([`${safeCol},${safeRow}`]);
for (const [nc, nr] of neighbors(safeCol, safeRow, g.cols, g.rows)) {
safe.add(`${nc},${nr}`);
}
// Candidate cells, excluding the safe pocket. If a board were ever too dense
// for the full pocket (it isn't, for our tiers), fall back to only the click.
let candidates = [];
for (let r = 0; r < g.rows; r++) {
for (let c = 0; c < g.cols; c++) {
if (!safe.has(`${c},${r}`)) candidates.push([c, r]);
}
}
if (candidates.length < g.mines) {
candidates = [];
for (let r = 0; r < g.rows; r++) {
for (let c = 0; c < g.cols; c++) {
if (c !== safeCol || r !== safeRow) candidates.push([c, r]);
}
}
}
// FisherYates partial shuffle to pick the first `mines` candidates.
for (let i = 0; i < g.mines; i++) {
const j = i + Math.floor(Math.random() * (candidates.length - i));
const tmp = candidates[i]; candidates[i] = candidates[j]; candidates[j] = tmp;
const [c, r] = candidates[i];
g.board[r][c].mine = true;
}
for (let r = 0; r < g.rows; r++) {
for (let c = 0; c < g.cols; c++) {
if (g.board[r][c].mine) continue;
let n = 0;
for (const [nc, nr] of neighbors(c, r, g.cols, g.rows)) {
if (g.board[nr][nc].mine) n++;
}
g.board[r][c].count = n;
}
}
}
// Flood-fill reveal from (col, row). Stepping onto a mine loses the game.
// Returns the list of newly revealed [col, row] cells (for animation).
export function reveal(g, col, row) {
if (g.state === 'won' || g.state === 'lost') return [];
if (!g.firstClickDone) {
placeMines(g, col, row);
g.firstClickDone = true;
g.state = 'playing';
}
const cell = g.board[row][col];
if (cell.revealed || cell.flagged) return [];
if (cell.mine) {
cell.revealed = true;
g.exploded = [col, row];
g.state = 'lost';
return [[col, row]];
}
const changed = [];
const stack = [[col, row]];
while (stack.length) {
const [c, r] = stack.pop();
const cur = g.board[r][c];
if (cur.revealed || cur.flagged || cur.mine) continue;
cur.revealed = true;
g.revealedCount++;
changed.push([c, r]);
if (cur.count === 0) {
for (const [nc, nr] of neighbors(c, r, g.cols, g.rows)) {
const nb = g.board[nr][nc];
if (!nb.revealed && !nb.flagged && !nb.mine) stack.push([nc, nr]);
}
}
}
checkWin(g);
return changed;
}
// Toggle a flag on a hidden cell. Returns true if the flag state changed.
export function toggleFlag(g, col, row) {
if (g.state === 'won' || g.state === 'lost') return false;
const cell = g.board[row][col];
if (cell.revealed) return false;
cell.flagged = !cell.flagged;
g.flagsUsed += cell.flagged ? 1 : -1;
return true;
}
// "Chord": on a revealed number whose adjacent flag count equals its value,
// reveal every non-flagged neighbour. A misplaced flag can detonate a mine.
// Returns the list of newly revealed cells (empty if the chord wasn't valid).
export function chord(g, col, row) {
if (g.state !== 'playing') return [];
const cell = g.board[row][col];
if (!cell.revealed || cell.count === 0) return [];
const nbrs = neighbors(col, row, g.cols, g.rows);
let flags = 0;
for (const [nc, nr] of nbrs) if (g.board[nr][nc].flagged) flags++;
if (flags !== cell.count) return [];
let changed = [];
for (const [nc, nr] of nbrs) {
const nb = g.board[nr][nc];
if (nb.revealed || nb.flagged) continue;
changed = changed.concat(reveal(g, nc, nr));
if (g.state === 'lost') break;
}
return changed;
}
// Win once every non-mine cell is revealed.
export function checkWin(g) {
if (g.state !== 'playing') return g.state === 'won';
const total = g.cols * g.rows;
if (g.revealedCount === total - g.mines) g.state = 'won';
return g.state === 'won';
}

View File

@ -62,6 +62,7 @@ import MonopolyGame from './games/monopoly/MonopolyGame.js';
import TriominoesGame from './games/triominoes/TriominoesGame.js';
import FreecellGame from './games/freecell/FreecellGame.js';
import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
const config = {
type: Phaser.AUTO,
@ -137,6 +138,7 @@ const config = {
TriominoesGame,
FreecellGame,
RushHourGame,
HexsweeperGame,
],
};

View File

@ -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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -77,3 +77,4 @@ registerGame({ slug: 'monopoly', name: 'Monopoly', category: '
registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 49 });
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });