diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 4ad3943..6c662e2 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 3c583ac..c73b9b2 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/hexsweeper/HexsweeperGame.js b/public/src/games/hexsweeper/HexsweeperGame.js new file mode 100644 index 0000000..9ae79ce --- /dev/null +++ b/public/src/games/hexsweeper/HexsweeperGame.js @@ -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]); + } +} diff --git a/public/src/games/hexsweeper/HexsweeperLogic.js b/public/src/games/hexsweeper/HexsweeperLogic.js new file mode 100644 index 0000000..ab18f56 --- /dev/null +++ b/public/src/games/hexsweeper/HexsweeperLogic.js @@ -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]); + } + } + } + + // Fisher–Yates 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'; +} diff --git a/public/src/main.js b/public/src/main.js index e3e6c97..d0fed6d 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index c0c29db..ee83d87 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame' }; + 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, diff --git a/server/games/registry.js b/server/games/registry.js index 61dc267..0194fc5 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 });