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:
parent
c01027e6c5
commit
147ef4b89b
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,7 @@ import MonopolyGame from './games/monopoly/MonopolyGame.js';
|
||||||
import TriominoesGame from './games/triominoes/TriominoesGame.js';
|
import TriominoesGame from './games/triominoes/TriominoesGame.js';
|
||||||
import FreecellGame from './games/freecell/FreecellGame.js';
|
import FreecellGame from './games/freecell/FreecellGame.js';
|
||||||
import RushHourGame from './games/rushhour/RushHourGame.js';
|
import RushHourGame from './games/rushhour/RushHourGame.js';
|
||||||
|
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -137,6 +138,7 @@ const config = {
|
||||||
TriominoesGame,
|
TriominoesGame,
|
||||||
FreecellGame,
|
FreecellGame,
|
||||||
RushHourGame,
|
RushHourGame,
|
||||||
|
HexsweeperGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame' };
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue