feat: add Stratego game with AI, setup phase, and battle animations

- Implement full Stratego rules, including hidden information, combat,
  and flag capture mechanics
- Add 5-level AI with heuristic evaluation, unknown enemy reasoning,
  and skill-scaled blunders/lookahead
- Create setup phase allowing piece swap and shuffle before battle
- Add detailed unit reference panel with scrollable ability notes
- Implement turn-based battle animations with sci-fi sound effects
- Integrate Stratego into game menu, room scene, and opponent selection
- Add spritesheet assets (stratego-pieces.png) and game menu icon
- Register Stratego in server game registry
This commit is contained in:
Brian Fertig 2026-06-07 09:53:58 -06:00
parent 701b4f75e6
commit 19898bf157
22 changed files with 1802 additions and 2 deletions

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"spellright.language": [
"en-US-10-1."
],
"spellright.documentTypes": [
"markdown",
"latex",
"plaintext"
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

View File

@ -0,0 +1,171 @@
// Stratego — heuristic opponent. No Phaser, no timers. It evaluates every legal
// move for one ply and scores it with a material + position + information model.
//
// Hidden information is the heart of it: the AI knows its own pieces, sees
// revealed enemies, and for the rest reasons over the *distribution* of enemy
// ranks still unaccounted for (full army captured currently-revealed). A
// piece that has moved can't be a Bomb or Flag, which sharpens the estimate.
// Skill 15 scales lookahead breadth, blunder rate, and pacing like the other AIs.
import {
GRID, FLAG, BOMB, SPY, SCOUT, MARSHAL, buildArmy, battleResult,
canMove, forwardDir,
} from './StrategoData.js';
import { allLegalMoves } from './StrategoLogic.js';
const SKILL_PROFILES = {
1: { topN: 8, blunder: 0.42, noise: 60, delay: [700, 1200] },
2: { topN: 5, blunder: 0.26, noise: 38, delay: [650, 1100] },
3: { topN: 3, blunder: 0.14, noise: 22, delay: [600, 1000] },
4: { topN: 2, blunder: 0.05, noise: 10, delay: [520, 900] },
5: { topN: 1, blunder: 0.00, noise: 4, delay: [440, 820] },
};
function profileFor(skill) { return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; }
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// Rough piece worth for the evaluator (Flag handled separately as a win).
const VALUE = {
[FLAG]: 10000, [BOMB]: 70,
1: 60, // Spy — kills the Marshal
2: 25, // Scout
3: 60, // Miner — defuses bombs
4: 40, // Sergeant
5: 55, // Lieutenant
6: 80, // Captain
7: 120, // Major
8: 170, // Colonel
9: 300, // General
[MARSHAL]: 450,
};
const val = (rank) => VALUE[rank] ?? 50;
const WIN = 1e7;
// Value swing of moverRank attacking a known defenderRank, from mover's view.
function knownAttackValue(moverRank, defenderRank) {
if (defenderRank === FLAG) return WIN;
const res = battleResult(moverRank, defenderRank);
if (res === 'attacker') return val(defenderRank);
if (res === 'defender') return -val(moverRank);
return val(defenderRank) - val(moverRank); // mutual loss
}
// The multiset of enemy ranks that are still on the board but unrevealed:
// full army pieces the enemy has lost enemy pieces currently shown.
function unknownEnemyCounts(state, enemy) {
const counts = {};
for (const r of buildArmy()) counts[r] = (counts[r] ?? 0) + 1;
for (const r of state.captured[enemy]) counts[r] = (counts[r] ?? 0) - 1;
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const p = state.board[r][c];
if (p && p.owner === enemy && p.revealed) counts[p.rank] = (counts[p.rank] ?? 0) - 1;
}
}
for (const k of Object.keys(counts)) if (counts[k] <= 0) delete counts[k];
return counts;
}
// Expected value of attacking an *unknown* enemy, averaged over its possible
// ranks. A target that has already moved can be neither a Bomb nor a Flag.
function expectedAttackValue(moverRank, counts, targetHasMoved) {
let total = 0, weight = 0;
for (const k of Object.keys(counts)) {
const rank = Number(k);
if (targetHasMoved && (rank === BOMB || rank === FLAG)) continue;
const w = counts[k];
total += w * knownAttackValue(moverRank, rank);
weight += w;
}
if (weight === 0) return knownAttackValue(moverRank, MARSHAL) * 0.5; // shouldn't happen
return total / weight;
}
const hasMoved = (p) => p.lastSquare !== null;
// Threat estimate: value we'd expect to lose if our piece sits at (r,c) and an
// adjacent enemy attacks it next turn. Uses known ranks where possible.
function squareThreat(state, seat, r, c, moverRank, counts) {
const enemy = 1 - seat;
let worst = 0;
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
const occ = state.board[nr][nc];
if (!occ || occ.owner !== enemy || !canMove(occ.rank)) continue;
// From the enemy's perspective they'd be attacking our (known to them only
// if we're revealed) piece; we just estimate the danger to us.
let loss;
if (occ.revealed) {
const res = battleResult(occ.rank, moverRank);
loss = res === 'attacker' ? val(moverRank) : res === 'both' ? val(moverRank) * 0.5 : 0;
} else {
// Unknown attacker: chance it outranks us, weighted across the distribution.
let bad = 0, wt = 0;
for (const k of Object.keys(counts)) {
const rank = Number(k);
if (rank === BOMB || rank === FLAG) continue; // can't attack us
const res = battleResult(rank, moverRank);
bad += counts[k] * (res === 'attacker' ? 1 : res === 'both' ? 0.5 : 0);
wt += counts[k];
}
loss = wt ? (bad / wt) * val(moverRank) : 0;
}
if (loss > worst) worst = loss;
}
return worst;
}
// Score one move for `seat` (higher = better).
function scoreMove(state, seat, m, counts) {
const mover = state.board[m.fr][m.fc];
let score = 0;
if (m.attack) {
const target = state.board[m.tr][m.tc];
score += target.revealed
? knownAttackValue(mover.rank, target.rank)
: expectedAttackValue(mover.rank, counts, hasMoved(target));
} else {
// Reward advancing toward the enemy; scouts get a touch more for ranging.
const advance = (m.tr - m.fr) * forwardDir(seat);
score += advance * (mover.rank === SCOUT ? 2.5 : 1.5);
// Discourage marching the Marshal/Spy out into the open early.
if (mover.rank === MARSHAL || mover.rank === SPY) score -= 2;
}
// Penalise landing where a stronger enemy can take us next turn.
score -= squareThreat(state, seat, m.tr, m.tc, mover.rank, counts) * 0.6;
return score;
}
// Choose a move for `seat`. Returns { type:'move', fr, fc, tr, tc } or null.
export function chooseMove(state, seat, skill = 3) {
const prof = profileFor(skill);
const moves = allLegalMoves(state, seat);
if (moves.length === 0) return null;
const counts = unknownEnemyCounts(state, 1 - seat);
const scored = moves.map((m) => ({ m, s: scoreMove(state, seat, m, counts) }));
scored.sort((a, b) => b.s - a.s);
let pick;
if (Math.random() < prof.blunder) {
const pool = scored.slice(0, Math.max(1, Math.ceil(scored.length / 3)));
pick = pool[Math.floor(Math.random() * pool.length)];
} else {
const pool = scored.slice(0, Math.min(prof.topN, scored.length));
let best = pool[0], bestV = -Infinity;
for (const e of pool) {
const v = e.s + (prof.noise ? (Math.random() * 2 - 1) * prof.noise : 0);
if (v > bestV) { bestV = v; best = e; }
}
pick = best;
}
const { fr, fc, tr, tc } = pick.m;
return { type: 'move', fr, fc, tr, tc };
}

View File

@ -0,0 +1,109 @@
// Stratego — static data. No Phaser, no game state: just the board geometry,
// the rank/army vocabulary, combat rules, the per-seat setup zones, and
// spritesheet frame helpers. Everything dynamic (the placed armies, whose turn,
// what's revealed) lives in StrategoLogic.js.
// 10×10 board.
export const GRID = 10;
// ── Lakes ────────────────────────────────────────────────────────────────────
// Two 2×2 impassable lakes straddle the centre rows (45). They block movement
// entirely — pieces can neither stop on nor pass through them (Scouts included).
export const LAKES = (() => {
const set = new Set();
for (const c0 of [2, 6]) {
for (let r = 4; r <= 5; r++) {
for (let c = c0; c <= c0 + 1; c++) set.add(r * GRID + c);
}
}
return set;
})();
export function isLake(r, c) { return LAKES.has(r * GRID + c); }
// ── Ranks & army ─────────────────────────────────────────────────────────────
// Rank values: 1 (Spy) … 10 (Marshal) plus two non-numeric special pieces —
// Bomb (BOMB) and Flag (FLAG). Higher rank wins a straight fight; the specials
// below override that. `count` is how many each side gets (40 total).
export const FLAG = 0; // immovable; capturing it wins
export const BOMB = 11; // immovable; destroys any attacker except a Miner (3)
export const SPY = 1;
export const SCOUT = 2;
export const MINER = 3;
export const MARSHAL = 10;
// rank → metadata. `frame` is the spritesheet cell (0=Flag, 1..10=rank, 11=Bomb).
export const RANKS = {
[FLAG]: { name: 'Flag', count: 1, frame: 0, canMove: false },
1: { name: 'Spy', count: 1, frame: 1, canMove: true },
2: { name: 'Scout', count: 8, frame: 2, canMove: true },
3: { name: 'Miner', count: 5, frame: 3, canMove: true },
4: { name: 'Sergeant', count: 4, frame: 4, canMove: true },
5: { name: 'Lieutenant', count: 4, frame: 5, canMove: true },
6: { name: 'Captain', count: 4, frame: 6, canMove: true },
7: { name: 'Major', count: 3, frame: 7, canMove: true },
8: { name: 'Colonel', count: 2, frame: 8, canMove: true },
9: { name: 'General', count: 1, frame: 9, canMove: true },
[MARSHAL]: { name: 'Marshal', count: 1, frame: 10, canMove: true },
[BOMB]: { name: 'Bomb', count: 6, frame: 11, canMove: false },
};
// The 40-piece roster for one side, as a flat list of rank values.
export function buildArmy() {
const army = [];
for (const key of Object.keys(RANKS)) {
const rank = Number(key);
for (let i = 0; i < RANKS[rank].count; i++) army.push(rank);
}
return army; // length 40
}
export const ARMY_SIZE = 40;
export function canMove(rank) { return RANKS[rank].canMove; }
export function rankName(rank) { return RANKS[rank].name; }
// ── Combat ───────────────────────────────────────────────────────────────────
// Resolve an attack: the moving (attacker) piece steps onto a square held by a
// defender. Returns who survives: 'attacker', 'defender', or 'both' (mutual
// destruction). Specials:
// • Flag — always loses (its capture ends the game; handled by caller).
// • Bomb — destroys any attacker, except a Miner (3) which defuses it.
// • Spy — defeats the Marshal (10) only when the Spy is the attacker.
// • else — higher rank wins; equal ranks destroy each other.
export function battleResult(attacker, defender) {
if (defender === FLAG) return 'attacker';
if (defender === BOMB) return attacker === MINER ? 'attacker' : 'defender';
if (attacker === SPY && defender === MARSHAL) return 'attacker';
if (attacker === defender) return 'both';
return attacker > defender ? 'attacker' : 'defender';
}
// ── Setup zones ──────────────────────────────────────────────────────────────
// Seat 0 (human, red) fills the bottom four rows; seat 1 (AI, blue) the top
// four. Each zone is 4 rows × 10 cols = 40 cells, exactly one army.
export function setupRows(seat) {
return seat === 0 ? [6, 7, 8, 9] : [0, 1, 2, 3];
}
export function inSetupZone(seat, r) {
return setupRows(seat).includes(r);
}
// The row of a seat's zone closest to its own edge (where the flag hides).
export function backRow(seat) { return seat === 0 ? 9 : 0; }
// The row of a seat's zone facing the enemy (its front line).
export function frontRow(seat) { return seat === 0 ? 6 : 3; }
// Forward direction (row delta) for a seat advancing toward the enemy.
export function forwardDir(seat) { return seat === 0 ? -1 : 1; }
// ── Colors ───────────────────────────────────────────────────────────────────
export const PLAYER_COLORS = [0xc2402f, 0x3f6fd0]; // red, blue
export const PLAYER_COLOR_HEX = ['#c2402f', '#3f6fd0'];
export const PLAYER_DARK = [0x7d2118, 0x223f86]; // shaded body edge
// ── Spritesheet / label helpers ──────────────────────────────────────────────
export function pieceFrame(rank) { return RANKS[rank].frame; }
// Corner label printed on movable pieces (Stratego prints the rank number).
// Bomb/Flag show a letter instead; immovable pieces have no number in the game.
export function rankLabel(rank) {
if (rank === FLAG) return 'F';
if (rank === BOMB) return 'B';
return String(rank);
}

View File

@ -0,0 +1,910 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { playSound, SFX, playScifiLaunch, playScifiExplode, playScifiRiser, playScifiReveal, playScifiWoosh } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import {
GRID, isLake, FLAG, BOMB, canMove, battleResult,
pieceFrame, rankLabel, rankName, RANKS,
PLAYER_COLORS, PLAYER_DARK,
} from './StrategoData.js';
import {
createInitialState, startBattle, shuffleSetup, swapSetup, applyMove,
legalMovesFor, isGameOver, keyOf,
} from './StrategoLogic.js';
import { chooseMove, nextThinkDelay } from './StrategoAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const TILE = 84, GAP = 2, PITCH = TILE + GAP;
const BOARD_W = GRID * PITCH - GAP; // 858
const BX0 = 120, BY0 = 130; // board top-left
const RAIL_X = BX0 + BOARD_W + 50; // ~1028
const RAIL_W = GAME_WIDTH - RAIL_X - 30; // ~862
const DEPTH = {
bg: 0, board: 5, cell: 6, lake: 7, mark: 9, piece: 12, glyph: 13,
sel: 16, ui: 40, drag: 55, popup: 60, banner: 90,
};
// Unit reference panel — special ability notes per rank.
const REF_SPECIALS = {
0: 'Capture the enemy Flag to win',
1: 'Defeats the Marshal when attacking',
2: 'Moves any number of squares in a straight line',
3: 'Defuses Bombs',
10: 'Highest rank · Vulnerable only to the Spy',
11: 'Destroys all attackers except Miners',
};
// Battle cinematic stage layout
const STAGE_SIZE = Math.round(140 / 0.78); // ~179 — body size that makes sprite render at 140×140
const STAGE_CX = BX0 + BOARD_W / 2; // 549 — horizontal center of board
const STAGE_CY = GAME_HEIGHT / 2; // 540 — vertical center of screen
const STAGE_OFFSET = 130; // px each piece sits from center
export default class StrategoGame extends Phaser.Scene {
constructor() { super('StrategoGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.humanSeat = 0;
this.aiSeat = 1;
this.gs = null;
this.busy = false;
this.selected = null; // {r,c} selected own piece (play)
this.legal = []; // legal destinations for selected piece
this.setupSel = null; // first-tapped piece during setup swap
this.dyn = [];
this.portraits = [];
this._endObjs = [];
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.hasPieces = this.textures.exists('stratego-pieces');
const opp = this.opponents[0] ?? null;
this.aiSkill = Math.max(1, Math.min(5, opp?.skill ?? 3));
const names = [auth.user?.username ?? 'You', opp?.name ?? 'Opponent'];
this.gs = createInitialState({ names });
this.buildBackground();
this.buildPortraits();
this.buildCellZones();
this.buildReferencePanel();
this.render();
}
// ── static chrome ──────────────────────────────────────────────────────────
buildBackground() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const g = this.add.graphics().setDepth(DEPTH.bg);
g.fillGradientStyle(0x16140f, 0x16140f, 0x080706, 0x080706, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
this.add.text(GAME_WIDTH / 2, 22, 'Stratego', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
}
buildPortraits() {
const r = 40;
const top = 150;
// Opponent (top of rail), You (below).
this.portraits[this.aiSeat] = createOpponentPortrait(this, this.opponents[0], RAIL_X + 52, top, r, DEPTH.ui + 1);
this.portraits[this.humanSeat] = createPlayerPortrait(this, RAIL_X + 52, top + 230, r, DEPTH.ui + 1, 'StrategoGame');
}
// Persistent invisible click targets, one per board cell. Created once.
buildCellZones() {
this.cellZones = [];
for (let r = 0; r < GRID; r++) {
this.cellZones[r] = [];
for (let c = 0; c < GRID; c++) {
const { x, y } = this.tileCenter(r, c);
const z = this.add.zone(x, y, TILE, TILE).setInteractive({ useHandCursor: true });
z.setDepth(DEPTH.sel);
z._rc = { r, c };
z.on('pointerdown', () => this.onCellDown(r, c));
z.on('pointerup', () => this.onCellUp(r, c));
this.cellZones[r][c] = z;
}
}
}
buildReferencePanel() {
const panelY = 556;
const panelH = (GAME_HEIGHT - 120) - 26 - 14 - panelY; // ends 14px above Shuffle button top → 364
const headerH = 36;
const contentY = panelY + headerH;
const contentH = panelH - headerH; // 328
const ROW_H = 76;
const rankOrder = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const totalContentH = rankOrder.length * ROW_H; // 912
// Panel chrome (never destroyed — built once).
const chrome = this.add.graphics().setDepth(DEPTH.ui);
chrome.fillStyle(0x080810, 0.88).fillRoundedRect(RAIL_X, panelY, RAIL_W, panelH, 12);
chrome.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(RAIL_X, panelY, RAIL_W, panelH, 12);
chrome.lineStyle(1, COLORS.accent, 0.2).lineBetween(RAIL_X + 8, contentY, RAIL_X + RAIL_W - 8, contentY);
this.add.text(RAIL_X + 14, panelY + 9, 'Unit Reference', {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1);
this.add.text(RAIL_X + RAIL_W - 14, panelY + 9, '↕ scroll', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1);
// Geometry mask clips content to the panel's inner area.
const maskGfx = this.add.graphics();
maskGfx.fillStyle(0xffffff).fillRect(RAIL_X, contentY, RAIL_W, contentH);
const mask = maskGfx.createGeometryMask();
// Scrollable container — children use coords relative to container origin.
this._refCont = this.add.container(RAIL_X, contentY).setDepth(DEPTH.ui + 1);
this._refCont.setMask(mask);
this._refScrollY = 0;
this._refMaxScroll = Math.max(0, totalContentH - contentH);
this._refContentY = contentY;
this._refContentH = contentH;
rankOrder.forEach((rank, i) => this._buildRefRow(rank, i * ROW_H));
// Wheel scrolling — only fires when pointer is over the content area.
this.input.on('wheel', (pointer, _gos, _dx, dy) => {
if (pointer.x < RAIL_X || pointer.x > RAIL_X + RAIL_W) return;
if (pointer.y < contentY || pointer.y > contentY + contentH) return;
this._refScrollY = Phaser.Math.Clamp(
this._refScrollY - dy * 0.5,
-this._refMaxScroll, 0,
);
this._refCont.y = this._refContentY + this._refScrollY;
});
}
_buildRefRow(rank, ry) {
const ICON = 50;
const textX = 70;
const info = RANKS[rank];
if (ry > 0) {
const sep = this.add.graphics();
sep.lineStyle(1, COLORS.accent, 0.12).lineBetween(8, ry, RAIL_W - 8, ry);
this._refCont.add(sep);
}
// Icon tile.
const iconBg = this.add.graphics();
iconBg.fillStyle(0x1a1a2e, 0.95).fillRoundedRect(8, ry + 13, ICON, ICON, 7);
iconBg.lineStyle(1, COLORS.accent, 0.35).strokeRoundedRect(8, ry + 13, ICON, ICON, 7);
this._refCont.add(iconBg);
if (this.hasPieces) {
const img = this.add.image(8 + ICON / 2, ry + 13 + ICON / 2, 'stratego-pieces', pieceFrame(rank))
.setDisplaySize(ICON * 0.78, ICON * 0.78);
this._refCont.add(img);
} else {
const lbl = this.add.text(8 + ICON / 2, ry + 13 + ICON / 2, rankLabel(rank), {
fontFamily: 'Righteous', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5);
this._refCont.add(lbl);
}
// Name (left) + rank/count (right) on the same line.
this._refCont.add(this.add.text(textX, ry + 10, info.name, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex,
}));
const rankStr = info.canMove ? `Rank ${rankLabel(rank)}` : 'Immovable';
const countStr = `${rankStr} · ×${info.count} per army`;
this._refCont.add(this.add.text(RAIL_W - 12, ry + 14, countStr, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(1, 0));
// Special ability (gold, larger).
const special = REF_SPECIALS[rank];
if (special) {
this._refCont.add(this.add.text(textX, ry + 38, special, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.goldHex,
}));
}
}
// ── geometry ────────────────────────────────────────────────────────────────
tileCenter(r, c) { return { x: BX0 + c * PITCH + TILE / 2, y: BY0 + r * PITCH + TILE / 2 }; }
reg(o) { this.dyn.push(o); return o; }
clearDyn() { for (const o of this.dyn) { try { o.destroy(); } catch { /* */ } } this.dyn = []; }
// What the human is allowed to see for a given piece.
faceUp(p) { return p.owner === this.humanSeat || p.revealed; }
// ── render ─────────────────────────────────────────────────────────────────
render() {
this.clearDyn();
this.drawBoard();
this.drawMarks();
this.drawPieces();
this.drawSelection();
this.drawRail();
this.drawControls();
this.drawStatus();
}
drawBoard() {
const g = this.reg(this.add.graphics().setDepth(DEPTH.board));
g.fillStyle(0x000000, 0.5).fillRoundedRect(BX0 - 14, BY0 - 14, BOARD_W + 28, BOARD_W + 28, 14);
g.lineStyle(2, COLORS.accent, 0.55).strokeRoundedRect(BX0 - 14, BY0 - 14, BOARD_W + 28, BOARD_W + 28, 14);
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const { x, y } = this.tileCenter(r, c);
if (isLake(r, c)) { this.drawLake(x, y); continue; }
const cg = this.reg(this.add.graphics().setDepth(DEPTH.cell));
const shade = (r + c) % 2 === 0 ? 0x586b46 : 0x4c5d3c;
cg.fillStyle(shade, 1).fillRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
cg.lineStyle(1, 0x2c3624, 0.7).strokeRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
}
}
}
drawLake(x, y) {
const g = this.reg(this.add.graphics().setDepth(DEPTH.lake));
g.fillStyle(0x2f6f8f, 1).fillRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
g.fillStyle(0x3f88a8, 1);
for (let i = 0; i < 3; i++) {
const wy = y - TILE / 4 + i * (TILE / 4);
g.fillRoundedRect(x - TILE / 2 + 8, wy, TILE - 16, 4, 2);
}
g.lineStyle(1, 0x1d4a60, 0.8).strokeRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
}
// Last-move trail + battle flash.
drawMarks() {
const lm = this.gs.lastMove;
if (lm) {
const g = this.reg(this.add.graphics().setDepth(DEPTH.mark));
g.lineStyle(3, COLORS.gold, 0.8);
for (const cell of [{ r: lm.fr, c: lm.fc }, { r: lm.tr, c: lm.tc }]) {
const { x, y } = this.tileCenter(cell.r, cell.c);
g.strokeRoundedRect(x - TILE / 2 + 2, y - TILE / 2 + 2, TILE - 4, TILE - 4, 5);
}
}
}
drawPieces() {
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const p = this.gs.board[r][c];
if (!p) continue;
if (this._hidden && this._hidden.has(keyOf(r, c))) continue; // animating
const { x, y } = this.tileCenter(r, c);
this.drawPiece(x, y, p, TILE - 8, this.faceUp(p));
}
}
}
// Draw a single piece. faceUp shows rank/character art; otherwise a coloured
// back. Used for the static board (adds via this.reg).
drawPiece(cx, cy, piece, size, faceUp) {
const objs = this.buildPieceObjects(piece, size, faceUp);
for (const o of objs) { o.x += cx; o.y += cy; this.reg(o.setDepth(o._depthBias + DEPTH.piece)); }
}
// Returns an array of GameObjects positioned around (0,0) for a piece, each
// tagged with `_depthBias`. Shared by the board renderer and the move animator.
buildPieceObjects(piece, size, faceUp) {
const out = [];
const half = size / 2;
const color = PLAYER_COLORS[piece.owner];
const dark = PLAYER_DARK[piece.owner];
// Body (medallion).
const body = this.add.graphics();
body.fillStyle(dark, 1).fillRoundedRect(-half, -half, size, size, 8);
body.fillStyle(color, 1).fillRoundedRect(-half + 3, -half + 3, size - 6, size - 9, 7);
body.fillStyle(0xffffff, 0.08).fillRoundedRect(-half + 3, -half + 3, size - 6, (size - 9) * 0.4, 7);
body.lineStyle(2, 0x000000, 0.35).strokeRoundedRect(-half, -half, size, size, 8);
body._depthBias = 0;
out.push(body);
if (!faceUp) {
// Face-down back: neutral insignia.
const g = this.add.graphics();
g.lineStyle(2, 0x000000, 0.25);
for (let i = -2; i <= 2; i++) g.lineBetween(-half + 8, i * 10, half - 8, i * 10 - 22);
g.fillStyle(0x000000, 0.18).fillCircle(0, 0, size * 0.22);
g.lineStyle(2, 0xffffff, 0.4).strokeCircle(0, 0, size * 0.22);
g._depthBias = 1;
out.push(g);
return out;
}
// Face-up: character art (spritesheet) or vector glyph, then corner number.
if (this.hasPieces) {
const img = this.add.image(0, 2, 'stratego-pieces', pieceFrame(piece.rank))
.setDisplaySize(size * 0.78, size * 0.78);
img._depthBias = 1;
out.push(img);
// Rank number, upper-left (as in the real game).
out.push(this.cornerLabel(piece.rank, size));
} else {
out.push(...this.vectorGlyph(piece.rank, size));
}
return out;
}
cornerLabel(rank, size) {
if (rank === FLAG || rank === BOMB) {
const t = this.add.text(-size / 2 + 6, -size / 2 + 4, rankLabel(rank), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.2)}px`, color: '#ffffff',
}).setOrigin(0, 0);
t._depthBias = 2; return t;
}
const t = this.add.text(-size / 2 + 6, -size / 2 + 4, rankLabel(rank), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.22)}px`, color: '#ffffff',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0);
t._depthBias = 2; return t;
}
// Code-drawn fallback when stratego-pieces.png isn't present yet. Big central
// glyph: a flag, a bomb, or the rank number.
vectorGlyph(rank, size) {
const out = [];
if (rank === FLAG) {
const g = this.add.graphics();
g.lineStyle(3, 0xffffff, 0.95).lineBetween(-size * 0.16, -size * 0.3, -size * 0.16, size * 0.32);
g.fillStyle(0xffffff, 0.95).fillTriangle(-size * 0.16, -size * 0.3, size * 0.26, -size * 0.16, -size * 0.16, -size * 0.02);
g._depthBias = 1; out.push(g);
return out;
}
if (rank === BOMB) {
const g = this.add.graphics();
g.fillStyle(0x161616, 0.92).fillCircle(0, size * 0.05, size * 0.28);
g.lineStyle(3, 0xffd27f, 0.95).lineBetween(size * 0.12, -size * 0.18, size * 0.24, -size * 0.32);
g.fillStyle(0xffd27f, 0.95).fillCircle(size * 0.24, -size * 0.32, 3.5);
g._depthBias = 1; out.push(g);
return out;
}
const t = this.add.text(0, 0, rankLabel(rank), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.5)}px`, color: '#ffffff',
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5);
t._depthBias = 1; out.push(t);
// Tiny name under the number for readability without art.
const n = this.add.text(0, size * 0.32, rankName(rank).slice(0, 8), {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(size * 0.12)}px`, color: '#f2ead8',
}).setOrigin(0.5);
n._depthBias = 2; out.push(n);
return out;
}
drawSelection() {
// Setup: highlight the first-tapped piece. Play: highlight selection + legal moves.
if (this.gs.phase === 'setup' && this.setupSel) {
const { x, y } = this.tileCenter(this.setupSel.r, this.setupSel.c);
this.reg(this.add.graphics().setDepth(DEPTH.sel))
.lineStyle(4, COLORS.accent, 1).strokeRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
}
if (this.selected) {
const { x, y } = this.tileCenter(this.selected.r, this.selected.c);
this.reg(this.add.graphics().setDepth(DEPTH.sel))
.lineStyle(4, COLORS.gold, 1).strokeRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 6);
for (const m of this.legal) {
const ctr = this.tileCenter(m.r, m.c);
const g = this.reg(this.add.graphics().setDepth(DEPTH.sel));
if (m.attack) {
g.lineStyle(4, COLORS.danger, 0.95).strokeRoundedRect(ctr.x - TILE / 2 + 2, ctr.y - TILE / 2 + 2, TILE - 4, TILE - 4, 5);
} else {
g.fillStyle(COLORS.gold, 0.45).fillCircle(ctr.x, ctr.y, TILE * 0.16);
}
}
}
}
// ── right rail ───────────────────────────────────────────────────────────────
drawRail() {
this.drawPlayerCard(this.aiSeat, RAIL_X, 96, false);
this.drawPlayerCard(this.humanSeat, RAIL_X, 326, true);
}
drawPlayerCard(seat, x, y, isYou) {
const w = RAIL_W, h = 220;
const isCurrent = seat === this.gs.current && this.gs.phase === 'play' && !isGameOver(this.gs);
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x000000, 0.55).fillRoundedRect(x, y, w, h, 12);
g.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : PLAYER_COLORS[seat], isCurrent ? 1 : 0.6)
.strokeRoundedRect(x, y, w, h, 12);
g.fillStyle(PLAYER_COLORS[seat], 1).fillCircle(x + 104, y + 26, 7);
const name = this.gs.names[seat] + (isYou ? ' (you)' : '');
this.reg(this.add.text(x + 120, y + 16, name, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
const alive = this.aliveCount(seat);
const sub = this.gs.phase === 'setup'
? (isYou ? 'Arrange your army' : 'Ready')
: (isCurrent ? 'Thinking…' : `${alive} pieces`);
this.reg(this.add.text(x + 120, y + 50, this.gs.phase === 'setup' ? sub : `${alive} pieces in play`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
if (!isYou && this.gs.phase === 'play') {
this.reg(this.add.text(x + 120, y + 78, `Skill ${this.aiSkill}/5`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
}
// Separator + captured pieces section.
g.lineStyle(1, COLORS.accent, 0.25).lineBetween(x + 12, y + 100, x + w - 12, y + 100);
this.reg(this.add.text(x + 14, y + 108, 'Captured', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
const ranks = this.gs.captured[seat];
const sorted = [...ranks].sort((a, b) => (a === FLAG ? -1 : b === FLAG ? 1 : b - a));
const S = 28, GAPX = 4, stride = S + GAPX;
const perRow = Math.floor((w - 28) / stride);
sorted.forEach((rank, i) => {
const col = i % perRow, row = Math.floor(i / perRow);
const px = x + 14 + col * stride, py = y + 126 + row * stride;
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
tg.fillStyle(0x000000, 0.4).fillRoundedRect(px, py, S, S, 4);
tg.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(px, py, S, S, 4);
this.reg(this.add.text(px + S / 2, py + S / 2, rankLabel(rank), {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
});
}
aliveCount(seat) {
let n = 0;
for (let r = 0; r < GRID; r++) for (let c = 0; c < GRID; c++)
if (this.gs.board[r][c]?.owner === seat) n++;
return n;
}
// ── controls (setup buttons / status) ─────────────────────────────────────────
drawControls() {
if (this._setupBtns) { for (const b of this._setupBtns) { try { b.destroy(); } catch { /* */ } } }
this._setupBtns = [];
if (this.gs.phase !== 'setup') return;
const y = GAME_HEIGHT - 120;
this._setupBtns.push(new Button(this, RAIL_X + 130, y, 'Shuffle', () => this.onShuffle(),
{ width: 230, height: 52, fontSize: 22 }).setDepth(DEPTH.ui + 2));
this._setupBtns.push(new Button(this, RAIL_X + 130, y + 64, 'Start Battle', () => this.onStartBattle(),
{ width: 230, height: 52, fontSize: 22, variant: 'primary' }).setDepth(DEPTH.ui + 2));
}
drawStatus() {
let msg, color = COLORS.textHex;
if (isGameOver(this.gs)) {
msg = this.gs.winner == null ? 'Stalemate — a draw.'
: this.gs.winner === this.humanSeat ? 'You captured the flag — victory!'
: 'Your flag was captured.';
color = COLORS.goldHex;
} else if (this.gs.phase === 'setup') {
msg = 'Drag/tap two of your pieces to swap them, then Start Battle.';
} else if (this.busy || this.gs.current !== this.humanSeat) {
msg = `${this.gs.names[this.gs.current]} is moving…`;
} else {
msg = this.selected ? 'Tap a highlighted square to move, or tap your piece again to cancel.'
: 'Your turn — tap one of your pieces.';
}
this.reg(this.add.text(BX0 - 2, BY0 + BOARD_W + 24, msg, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color,
}).setDepth(DEPTH.ui));
}
// ── input ────────────────────────────────────────────────────────────────────
onCellDown(r, c) {
if (this.gs.phase === 'setup') { this._downKey = keyOf(r, c); return; }
if (this.gs.phase !== 'play' || this.busy || this.gs.current !== this.humanSeat) return;
this.handlePlayTap(r, c);
}
onCellUp(r, c) {
if (this.gs.phase !== 'setup') return;
const upKey = keyOf(r, c);
const a = this._downKey;
this._downKey = null;
if (a == null) return;
if (a === upKey) { this.handleSetupTap(r, c); return; }
// Drag from a→up: swap if both are your pieces.
this.trySetupSwap(a, upKey);
}
handleSetupTap(r, c) {
const p = this.gs.board[r][c];
if (!p || p.owner !== this.humanSeat) { this.setupSel = null; this.render(); return; }
if (!this.setupSel) { this.setupSel = { r, c }; this.render(); return; }
const a = keyOf(this.setupSel.r, this.setupSel.c);
if (a === keyOf(r, c)) { this.setupSel = null; this.render(); return; }
this.trySetupSwap(a, keyOf(r, c));
}
trySetupSwap(k1, k2) {
const next = swapSetup(this.gs, k1, k2);
if (next !== this.gs) { this.gs = next; playSound(this, SFX.PIECE_CLICK); }
this.setupSel = null;
this.render();
}
handlePlayTap(r, c) {
const p = this.gs.board[r][c];
// Tapping a legal destination of the current selection → move.
if (this.selected) {
const m = this.legal.find((q) => q.r === r && q.c === c);
if (m) { this.doHumanMove(this.selected.r, this.selected.c, r, c); return; }
// Tapping the same piece cancels; tapping another own piece reselects.
if (this.selected.r === r && this.selected.c === c) { this.clearSelection(); this.render(); return; }
}
if (p && p.owner === this.humanSeat && canMove(p.rank)) {
const moves = legalMovesFor(this.gs, r, c);
if (moves.length === 0) { this.clearSelection(); this.render(); return; }
this.selected = { r, c };
this.legal = moves;
playSound(this, SFX.PIECE_CLICK);
this.render();
return;
}
this.clearSelection();
this.render();
}
clearSelection() { this.selected = null; this.legal = []; }
// ── setup actions ─────────────────────────────────────────────────────────────
onShuffle() {
if (this.gs.phase !== 'setup') return;
this.gs = shuffleSetup(this.gs, this.humanSeat);
this.setupSel = null;
playSound(this, SFX.CARD_SHUFFLE);
this.render();
}
onStartBattle() {
if (this.gs.phase !== 'setup') return;
this.gs = startBattle(this.gs);
this.setupSel = null;
playSound(this, SFX.PIECE_CLICK);
this.render();
this.advance();
}
// ── move execution (human + AI share the animation) ───────────────────────────
doHumanMove(fr, fc, tr, tc) {
this.clearSelection();
this.busy = true;
this.animateMove(fr, fc, tr, tc, () => {
this.gs = applyMove(this.gs, fr, fc, tr, tc);
this.busy = false;
this.advance();
});
}
// Slide a ghost piece from source to destination; if it's an attack, briefly
// reveal both combatants with a clash flash before resolving.
animateMove(fr, fc, tr, tc, onDone, slideDuration = 230) {
const mover = this.gs.board[fr][fc];
const target = this.gs.board[tr][tc];
const from = this.tileCenter(fr, fc);
const to = this.tileCenter(tr, tc);
const size = TILE - 8;
// Hide the static board piece(s) involved so they don't double the ghosts.
this._hidden = new Set([keyOf(fr, fc)]);
if (target) this._hidden.add(keyOf(tr, tc));
this.render();
const finish = () => { this._hidden = null; onDone(); };
// Mover ghost: face-up if the human owns it or it's about to be revealed
// (any attack reveals it). Quiet AI moves stay face-down.
const moverFaceUp = this.faceUp(mover) || !!target;
const ghost = this.makeContainer(mover, size, moverFaceUp).setDepth(DEPTH.drag);
ghost.setPosition(from.x, from.y);
this.tweens.add({
targets: ghost, x: to.x, y: to.y, duration: slideDuration, ease: 'Cubic.easeInOut',
onComplete: () => {
if (!target) { ghost.destroy(); playSound(this, SFX.PIECE_CLICK); finish(); return; }
this.playBattle(ghost, mover, target, to, finish);
},
});
}
playBattle(ghost, mover, target, at, onDone) {
playSound(this, SFX.PIECE_CLICK);
const res = battleResult(mover.rank, target.rank);
const defenderWasHidden = !this.faceUp(target);
// Destroy the small incoming ghost — replaced by larger stage containers.
ghost.destroy();
// Human piece is always left; AI piece always right.
const humanPiece = mover.owner === this.humanSeat ? mover : target;
const aiPiece = mover.owner === this.humanSeat ? target : mover;
const humanStageX = STAGE_CX - STAGE_OFFSET;
const aiStageX = STAGE_CX + STAGE_OFFSET;
// Track all transient objects so we can clean up reliably.
const stageObjs = [];
// ── Phase 0: dim + slide to stage ────────────────────────────────────────
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0)
.setDepth(DEPTH.drag - 5);
stageObjs.push(dim);
this.tweens.add({ targets: dim, fillAlpha: 0.78, duration: 350 });
let humanCont = this.makeContainer(humanPiece, STAGE_SIZE, true)
.setPosition(at.x, at.y).setDepth(DEPTH.drag);
let aiCont = this.makeContainer(aiPiece, STAGE_SIZE, !defenderWasHidden)
.setPosition(at.x, at.y).setDepth(DEPTH.drag);
stageObjs.push(humanCont, aiCont);
playScifiWoosh(this);
this.tweens.add({ targets: humanCont, x: humanStageX, y: STAGE_CY, duration: 2000, ease: 'Cubic.easeOut' });
this.tweens.add({
targets: aiCont, x: aiStageX, y: STAGE_CY, duration: 2000, ease: 'Cubic.easeOut',
onComplete: () => this._battleReveal(
aiCont, aiPiece, aiStageX, defenderWasHidden, stageObjs,
(resolvedAiCont) => {
aiCont = resolvedAiCont;
this._battleRankNumbers(
humanCont, aiCont, humanPiece, aiPiece, humanStageX, aiStageX, stageObjs,
(humanRankTxt, aiRankTxt) => {
this._battleResolve(
res, mover, humanCont, aiCont, humanPiece, aiPiece,
humanStageX, aiStageX, humanRankTxt, aiRankTxt, at, dim, stageObjs, onDone,
);
},
);
},
),
});
}
// ── Phase 1: shake + flip-reveal the AI piece if it was hidden ──────────────
_battleReveal(aiCont, aiPiece, aiStageX, defenderWasHidden, stageObjs, onDone) {
if (!defenderWasHidden) { onDone(aiCont); return; }
playScifiRiser(this);
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1, duration: 1500, ease: 'Linear',
onUpdate: () => {
const amp = prog.t * 22;
const freq = 7 + prog.t * 5;
aiCont.x = aiStageX + Math.sin(prog.t * freq * Math.PI * 2) * amp;
aiCont.y = STAGE_CY + Math.cos(prog.t * freq * Math.PI * 2 + 1) * amp * 0.25;
},
onComplete: () => {
aiCont.x = aiStageX;
aiCont.y = STAGE_CY;
// Fold out (scaleX → 0).
this.tweens.add({
targets: aiCont, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
onComplete: () => {
// Swap to face-up container, fold in (scaleX 0 → 1).
playScifiReveal(this);
const idx = stageObjs.indexOf(aiCont);
try { aiCont.destroy(); } catch {}
const revealed = this.makeContainer(aiPiece, STAGE_SIZE, true)
.setPosition(aiStageX, STAGE_CY).setScale(0, 1).setDepth(DEPTH.drag);
if (idx >= 0) stageObjs[idx] = revealed; else stageObjs.push(revealed);
this.tweens.add({
targets: revealed, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
onComplete: () => onDone(revealed),
});
},
});
},
});
}
// ── Phase 2: rank numbers rise toward center ────────────────────────────────
_battleRankNumbers(humanCont, aiCont, humanPiece, aiPiece, humanStageX, aiStageX, stageObjs, onDone) {
const mkRankTxt = (piece, x) => {
const px = 28 + piece.rank * 9;
return this.add.text(x, STAGE_CY, rankLabel(piece.rank), {
fontFamily: 'Righteous', fontSize: `${px}px`, color: '#ffffff',
stroke: '#000000', strokeThickness: Math.max(3, Math.round(px * 0.08)),
}).setOrigin(0.5).setDepth(DEPTH.drag + 2).setScale(0.2).setAlpha(0);
};
const humanTxt = mkRankTxt(humanPiece, humanStageX);
const aiTxt = mkRankTxt(aiPiece, aiStageX);
stageObjs.push(humanTxt, aiTxt);
this.tweens.add({
targets: humanTxt, scale: 1, alpha: 1, y: STAGE_CY - 120, x: humanStageX + 35,
duration: 750, ease: 'Cubic.easeOut',
});
this.tweens.add({
targets: aiTxt, scale: 1, alpha: 1, y: STAGE_CY - 120, x: aiStageX - 35,
duration: 750, ease: 'Cubic.easeOut',
onComplete: () => onDone(humanTxt, aiTxt),
});
}
// ── Phase 3 + 4: shoot, explode, fade loser; return winner ──────────────────
_battleResolve(res, mover, humanCont, aiCont, humanPiece, aiPiece,
humanStageX, aiStageX, humanRankTxt, aiRankTxt, at, dim, stageObjs, onDone) {
// Map result to containers.
const attackerIsHuman = mover.owner === this.humanSeat;
let winnerCont, loserCont, winnerTxt, loserTxt;
if (res === 'attacker') {
[winnerCont, loserCont] = attackerIsHuman ? [humanCont, aiCont] : [aiCont, humanCont];
[winnerTxt, loserTxt] = attackerIsHuman ? [humanRankTxt, aiRankTxt] : [aiRankTxt, humanRankTxt];
} else if (res === 'defender') {
[winnerCont, loserCont] = attackerIsHuman ? [aiCont, humanCont] : [humanCont, aiCont];
[winnerTxt, loserTxt] = attackerIsHuman ? [aiRankTxt, humanRankTxt] : [humanRankTxt, aiRankTxt];
}
// Outcome label — large yellow text centered above the rank numbers.
const humanWins = res === 'attacker' ? attackerIsHuman : !attackerIsHuman;
const outcomeStr = res === 'both' ? 'Both Lose' : humanWins ? 'Win' : 'Lose';
const outcomeLabel = this.add.text(STAGE_CX, STAGE_CY - 200, outcomeStr, {
fontFamily: 'Righteous', fontSize: '72px', color: '#ffdd00',
stroke: '#000000', strokeThickness: 8,
}).setOrigin(0.5).setDepth(DEPTH.drag + 3).setAlpha(0);
stageObjs.push(outcomeLabel);
this.tweens.add({ targets: outcomeLabel, alpha: 1, duration: 300, ease: 'Power2' });
const fireShot = (srcX, srcY, dstX, dstY, onHit) => {
playScifiLaunch(this);
const shot = this.add.circle(srcX, STAGE_CY, 6, 0xff8800, 1).setDepth(DEPTH.drag + 3);
stageObjs.push(shot);
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1, duration: 400, ease: 'Linear',
onUpdate: () => {
shot.x = srcX + (dstX - srcX) * prog.t;
shot.y = srcY + (dstY - srcY) * prog.t - 90 * Math.sin(prog.t * Math.PI);
},
onComplete: () => { try { shot.destroy(); } catch {} onHit(); },
});
};
const fadeOut = (cont, txt) => {
this.tweens.add({ targets: cont, alpha: 0, duration: 450, delay: 120 });
if (txt) this.tweens.add({ targets: txt, alpha: 0, duration: 300 });
};
const finishWinner = () => {
this.time.delayedCall(1900, () => {
// Fade winning rank number and outcome label together with the undim.
if (winnerTxt) this.tweens.add({ targets: winnerTxt, alpha: 0, duration: 500 });
this.tweens.add({ targets: outcomeLabel, alpha: 0, duration: 500 });
// Return winner piece to its board position and undim simultaneously.
this.tweens.add({
targets: winnerCont, x: at.x, y: at.y, duration: 500, ease: 'Cubic.easeInOut',
});
this.tweens.add({
targets: dim, fillAlpha: 0, duration: 500,
onComplete: () => { cleanup(); onDone(); },
});
});
};
const cleanup = () => { for (const o of stageObjs) { try { o.destroy(); } catch {} } };
if (res === 'both') {
// Tie: both shoot simultaneously, both explode/fade.
fireShot(humanStageX, STAGE_CY, aiStageX, STAGE_CY, () => {
this._spawnExplosions(aiStageX, STAGE_CY);
fadeOut(aiCont, aiRankTxt);
});
fireShot(aiStageX, STAGE_CY, humanStageX, STAGE_CY, () => {
this._spawnExplosions(humanStageX, STAGE_CY);
fadeOut(humanCont, humanRankTxt);
});
// After both explosions settle, fade outcome label and undim.
this.time.delayedCall(2700, () => {
this.tweens.add({ targets: outcomeLabel, alpha: 0, duration: 400 });
this.tweens.add({
targets: dim, fillAlpha: 0, duration: 400,
onComplete: () => { cleanup(); onDone(); },
});
});
} else {
// Winner shoots loser.
const winnerX = winnerCont === humanCont ? humanStageX : aiStageX;
const loserX = loserCont === humanCont ? humanStageX : aiStageX;
fireShot(winnerX, STAGE_CY, loserX, STAGE_CY, () => {
this._spawnExplosions(loserX, STAGE_CY);
fadeOut(loserCont, loserTxt);
finishWinner();
});
}
}
// Three staggered expanding rings at the explosion point.
_spawnExplosions(cx, cy) {
const jitters = [[0, 0], [-18, -12], [14, 20]];
jitters.forEach(([jx, jy], i) => {
this.time.delayedCall(i * 90, () => {
if (i === 0) playScifiExplode(this);
const ring = this.add.circle(cx + jx, cy + jy, 10, 0xff5500, 0.9).setDepth(DEPTH.drag + 2);
this.tweens.add({
targets: ring, scale: 7, alpha: 0, duration: 520, ease: 'Cubic.easeOut',
onComplete: () => { try { ring.destroy(); } catch {} },
});
});
});
}
// Build a tweenable container for a piece (origin centred).
makeContainer(piece, size, faceUp) {
const cont = this.add.container(0, 0);
const objs = this.buildPieceObjects(piece, size, faceUp);
objs.sort((a, b) => a._depthBias - b._depthBias);
for (const o of objs) cont.add(o);
return cont;
}
// ── turn driver ────────────────────────────────────────────────────────────────
advance() {
this.render();
if (isGameOver(this.gs)) { this.busy = false; this.showWinner(); return; }
if (this.gs.current === this.humanSeat) { this.busy = false; return; }
this.aiTurn();
}
aiTurn() {
this.busy = true;
this.render();
this.time.delayedCall(nextThinkDelay(this.aiSkill), () => {
const mv = chooseMove(this.gs, this.aiSeat, this.aiSkill);
if (!mv) { this.busy = false; this.advance(); return; }
this.animateMove(mv.fr, mv.fc, mv.tr, mv.tc, () => {
this.gs = applyMove(this.gs, mv.fr, mv.fc, mv.tr, mv.tc);
this.busy = false;
this.advance();
}, 1200);
});
}
// ── end ──────────────────────────────────────────────────────────────────────
showWinner() {
const youWon = this.gs.winner === this.humanSeat;
const draw = this.gs.winner == null;
const accent = draw ? COLORS.accent : PLAYER_COLORS[this.gs.winner];
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(DEPTH.banner);
const panel = this.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2).setDepth(DEPTH.banner + 1);
const g = this.add.graphics();
g.fillStyle(0x14110b, 0.96).fillRoundedRect(-280, -120, 560, 240, 16);
g.lineStyle(3, accent, 1).strokeRoundedRect(-280, -120, 560, 240, 16);
panel.add(g);
panel.add(this.add.text(0, -50, draw ? 'Draw' : youWon ? 'Victory!' : 'Defeat', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5));
panel.add(this.add.text(0, 4, draw ? 'Neither army could force the flag.'
: youWon ? 'You captured the enemy flag.' : 'The enemy captured your flag.', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5));
new Button(this, GAME_WIDTH / 2 - 130, GAME_HEIGHT / 2 + 74, 'Rematch',
() => this.scene.restart(), { width: 220, height: 50 }).setDepth(DEPTH.banner + 2);
new Button(this, GAME_WIDTH / 2 + 130, GAME_HEIGHT / 2 + 74, 'Back to menu',
() => this.scene.start('GameMenu'), { width: 220, height: 50, variant: 'ghost' }).setDepth(DEPTH.banner + 2);
if (youWon) playSound(this, SFX.VICTORY_SHORT);
this._endObjs = [overlay, panel];
}
}

View File

@ -0,0 +1,337 @@
// Stratego — pure game engine. No Phaser, no rendering, no timers. Mutators
// deep-clone the state and return the next one so the scene and AI can look
// ahead freely. A match has two phases: SETUP (arrange your 40 pieces) and PLAY
// (alternating single moves until a flag is captured or a side is stuck).
//
// Hidden information: every piece carries a `revealed` flag. Your own pieces are
// always known to you; an enemy piece is hidden until it fights and survives,
// after which it stays revealed (digital-standard). The engine never hides data
// from itself — the *renderer* decides what each viewer may see.
import {
GRID, isLake, FLAG, BOMB, SCOUT, RANKS, buildArmy, canMove,
battleResult, setupRows, backRow, PLAYER_COLORS, PLAYER_COLOR_HEX,
} from './StrategoData.js';
// ── tiny seedable RNG (deterministic when a seed is supplied) ────────────────
function makeRng(seed) {
if (seed == null) return Math.random;
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
export const keyOf = (r, c) => r * GRID + c;
const inBounds = (r, c) => r >= 0 && r < GRID && c >= 0 && c < GRID;
// ── piece + clone ─────────────────────────────────────────────────────────────
let _idSeq = 0;
function makePiece(owner, rank) {
return { id: ++_idSeq, owner, rank, revealed: false, lastSquare: null, bounceN: 0 };
}
function clonePiece(p) {
return p ? { id: p.id, owner: p.owner, rank: p.rank, revealed: p.revealed,
lastSquare: p.lastSquare, bounceN: p.bounceN } : null;
}
export function cloneState(s) {
return {
board: s.board.map((row) => row.map(clonePiece)),
phase: s.phase,
current: s.current,
winner: s.winner,
names: [...s.names],
colors: [...s.colors],
colorHex: [...s.colorHex],
captured: [s.captured[0].slice(), s.captured[1].slice()],
lastMove: s.lastMove ? { ...s.lastMove } : null,
lastBattle: s.lastBattle ? { ...s.lastBattle } : null,
plySinceCapture: s.plySinceCapture,
};
}
// ── heuristic auto-arrangement ───────────────────────────────────────────────
// Produces a sensible 40-piece formation for one seat: flag in a back corner
// ringed by bombs, a couple of bombs salted along the front line, scouts/miners
// pushed forward, the spy tucked back, and the heavy ranks held off the very
// front so they aren't traded away cheaply. Returns Map<cellKey, rank>.
//
// Lines run front→back: index 0 is the row facing the enemy, 3 is the home edge.
function arrangeArmy(seat, rng) {
const rows = setupRows(seat); // e.g. [6,7,8,9] (seat 0)
// Order front→back, where "front" is the row nearest the centre/enemy and
// "back" is the home edge. Seat 0 (bottom, rows 69): front=6 … back=9, so
// the natural order is already front→back. Seat 1 (top, rows 03): front=3 …
// back=0, so reverse it.
const lines = seat === 0 ? rows.slice() // [6,7,8,9] front→back
: rows.slice().reverse(); // [3,2,1,0] front→back
// lines[0] = front row (toward centre), lines[3] = back row (home edge).
const back = lines[3];
const place = new Map();
const taken = (r, c) => place.has(keyOf(r, c));
const put = (r, c, rank) => place.set(keyOf(r, c), rank);
const pool = []; // ranks still to place
for (const key of Object.keys(RANKS)) {
const rank = Number(key);
if (rank === FLAG || rank === BOMB) continue; // handled explicitly
for (let i = 0; i < RANKS[rank].count; i++) pool.push(rank);
}
// 1) Flag in the back row, off-centre.
const flagCol = [0, 1, 2, 7, 8, 9][Math.floor(rng() * 6)];
put(back, flagCol, FLAG);
// 2) Ring the flag with up to 3 bombs (sides on the back row + the cell in
// front of it on line 2).
let bombsLeft = RANKS[BOMB].count; // 6
const guard = [
[back, flagCol - 1], [back, flagCol + 1], [lines[2], flagCol],
];
for (const [r, c] of guard) {
if (bombsLeft > 0 && inBounds(r, c) && !taken(r, c)) { put(r, c, BOMB); bombsLeft--; }
}
// 3) Scatter the remaining bombs on the forward two lines as tripwires.
const fwdCols = shuffle([...Array(GRID).keys()], rng);
let fi = 0;
while (bombsLeft > 0 && fi < fwdCols.length) {
const line = bombsLeft % 2 === 0 ? lines[0] : lines[1];
const c = fwdCols[fi++];
if (!taken(line, c)) { put(line, c, BOMB); bombsLeft--; }
}
// 4) Fill the rest. Pair each free cell (by line) with a rank whose preferred
// "frontness" matches, then assign forward-leaning ranks to forward cells.
const PREF = { 1: 3, 2: 0, 3: 0, 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 2, 10: 2 };
const free = [];
for (let li = 0; li < 4; li++) {
const r = lines[li];
for (let c = 0; c < GRID; c++) if (!taken(r, c)) free.push({ r, c, li });
}
// Sort cells front→back with a little noise; sort ranks by preferred frontness.
free.sort((a, b) => (a.li - b.li) + (rng() - 0.5) * 0.9);
pool.sort((a, b) => (PREF[a] - PREF[b]) + (rng() - 0.5) * 0.9);
for (let i = 0; i < free.length; i++) put(free[i].r, free[i].c, pool[i]);
return place;
}
// ── setup ────────────────────────────────────────────────────────────────────
export function createInitialState({ names = ['You', 'Opponent'], seed = null } = {}) {
const rng = makeRng(seed);
const board = Array.from({ length: GRID }, () => new Array(GRID).fill(null));
for (let seat = 0; seat < 2; seat++) {
const place = arrangeArmy(seat, rng);
for (const [k, rank] of place) {
const r = Math.floor(k / GRID), c = k % GRID;
board[r][c] = makePiece(seat, rank);
}
}
return {
board,
phase: 'setup',
current: 0,
winner: null,
names: [names[0] ?? 'You', names[1] ?? 'Opponent'],
colors: [...PLAYER_COLORS],
colorHex: [...PLAYER_COLOR_HEX],
captured: [[], []], // captured[seat] = ranks that seat LOST
lastMove: null,
lastBattle: null,
plySinceCapture: 0,
};
}
// Re-roll a seat's whole formation (setup only).
export function shuffleSetup(state, seat) {
const s = cloneState(state);
if (s.phase !== 'setup') return s;
const place = arrangeArmy(seat, Math.random);
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) if (s.board[r][c]?.owner === seat) s.board[r][c] = null;
}
for (const [k, rank] of place) {
s.board[Math.floor(k / GRID)][k % GRID] = makePiece(seat, rank);
}
return s;
}
// Swap two of a seat's own pieces during setup (drag-swap). Keys are cell keys.
export function swapSetup(state, k1, k2) {
const s = cloneState(state);
if (s.phase !== 'setup' || k1 === k2) return s;
const r1 = Math.floor(k1 / GRID), c1 = k1 % GRID;
const r2 = Math.floor(k2 / GRID), c2 = k2 % GRID;
const a = s.board[r1][c1], b = s.board[r2][c2];
if (!a || !b || a.owner !== b.owner) return s;
s.board[r1][c1] = b; s.board[r2][c2] = a;
return s;
}
// Leave setup and begin the battle. The human (seat 0) moves first.
export function startBattle(state) {
const s = cloneState(state);
if (s.phase !== 'setup') return s;
s.phase = 'play';
s.current = 0;
return s;
}
// ── queries ──────────────────────────────────────────────────────────────────
export function pieceAt(state, r, c) { return state.board[r][c]; }
export function isGameOver(state) { return state.phase === 'over'; }
export function winner(state) { return state.winner; }
export function currentName(state) { return state.names[state.current]; }
// Legal destinations for the piece at (r,c): a list of { r, c, attack }.
// Scouts (rank 2) slide any distance in a straight line over empty squares and
// may attack the first enemy they reach; everyone else steps one orthogonal
// square. Lakes and friendly pieces block. The two-square (anti-shuffle) rule
// forbids a piece from immediately bouncing back once it has already done so.
export function legalMovesFor(state, r, c) {
const p = state.board[r][c];
if (!p || !canMove(p.rank)) return [];
const out = [];
const blockedReturn = (tr, tc) => p.bounceN >= 2 && p.lastSquare === keyOf(tr, tc);
const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [dr, dc] of DIRS) {
let nr = r + dr, nc = c + dc;
const maxStep = p.rank === SCOUT ? GRID : 1;
for (let step = 0; step < maxStep; step++, nr += dr, nc += dc) {
if (!inBounds(nr, nc) || isLake(nr, nc)) break;
const occ = state.board[nr][nc];
if (!occ) {
if (!blockedReturn(nr, nc)) out.push({ r: nr, c: nc, attack: false });
continue; // scout keeps sliding
}
if (occ.owner !== p.owner && !blockedReturn(nr, nc)) {
out.push({ r: nr, c: nc, attack: true }); // can attack the first enemy
}
break; // any piece blocks further travel
}
}
return out;
}
export function hasAnyLegalMove(state, seat) {
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const p = state.board[r][c];
if (p && p.owner === seat && canMove(p.rank) && legalMovesFor(state, r, c).length) return true;
}
}
return false;
}
export function allLegalMoves(state, seat) {
const moves = [];
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const p = state.board[r][c];
if (!p || p.owner !== seat || !canMove(p.rank)) continue;
for (const m of legalMovesFor(state, r, c)) {
moves.push({ fr: r, fc: c, tr: m.r, tc: m.c, attack: m.attack });
}
}
}
return moves;
}
// Plies without a capture before we call a stalemate draw (prevents endless
// manoeuvring when neither side can force the flag).
const DRAW_PLY_LIMIT = 360;
// ── the move ─────────────────────────────────────────────────────────────────
// Move (or attack) the piece at (fr,fc) to (tr,tc). Resolves combat, reveals,
// flag-capture / no-move wins, and the draw cap, then flips the turn.
export function applyMove(state, fr, fc, tr, tc) {
const s = cloneState(state);
if (s.phase !== 'play') return s;
const mover = s.board[fr][fc];
if (!mover || mover.owner !== s.current) return s;
if (!legalMovesFor(s, fr, fc).some((m) => m.r === tr && m.c === tc)) return s;
const target = s.board[tr][tc];
// Two-square bookkeeping: a move is an oscillation step if it returns to the
// square the piece came from on its previous move.
const fromKey = keyOf(fr, fc);
mover.bounceN = (mover.lastSquare === keyOf(tr, tc)) ? mover.bounceN + 1 : 0;
mover.lastSquare = fromKey;
s.lastMove = { fr, fc, tr, tc, attack: !!target };
s.lastBattle = null;
if (!target) {
// Quiet move.
s.board[tr][tc] = mover;
s.board[fr][fc] = null;
s.plySinceCapture++;
} else {
// Combat. Both pieces are exposed; the survivor stays revealed.
const res = battleResult(mover.rank, target.rank);
s.lastBattle = {
r: tr, c: tc, attacker: mover.rank, defender: target.rank,
attackerOwner: mover.owner, defenderOwner: target.owner, result: res,
};
s.plySinceCapture = 0;
s.board[fr][fc] = null;
if (target.rank === FLAG) {
// Flag captured — immediate win for the attacker.
mover.revealed = true;
s.board[tr][tc] = mover;
s.captured[target.owner].push(FLAG);
s.phase = 'over';
s.winner = mover.owner;
return s;
}
if (res === 'attacker') {
mover.revealed = true;
s.board[tr][tc] = mover;
s.captured[target.owner].push(target.rank);
} else if (res === 'defender') {
target.revealed = true;
s.board[tr][tc] = target;
s.captured[mover.owner].push(mover.rank);
} else { // both destroyed
s.board[tr][tc] = null;
s.captured[mover.owner].push(mover.rank);
s.captured[target.owner].push(target.rank);
}
}
// Turn passes. If the next side can't move, the side that just moved wins.
const next = 1 - s.current;
if (!hasAnyLegalMove(s, next)) {
s.phase = 'over';
s.winner = s.current;
return s;
}
if (s.plySinceCapture >= DRAW_PLY_LIMIT) {
s.phase = 'over';
s.winner = null; // draw
return s;
}
s.current = next;
return s;
}
// Uniform entry point for the AI driver.
export function applyAction(state, action) {
if (action.type === 'move') return applyMove(state, action.fr, action.fc, action.tr, action.tc);
return state;
}

View File

@ -0,0 +1,156 @@
# Stratego — Sprite Build Guide
Everything you need to build the art for Stratego. There are **two separate
deliverables**:
1. **`stratego-pieces.png`** — the unit character spritesheet (the main job). ← this doc
2. **One game-menu icon** at frame 46 of the existing `game-icons.png` (brief note at bottom).
The companion file `sprite.md` is the terse code-side spec; this file is the
artist-facing build guide. If the two ever disagree, the numbers here were read
straight from the running code.
---
## 1. `stratego-pieces.png` — quick facts
| Property | Value |
|---|---|
| **File path** | `public/assets/images/stratego-pieces.png` |
| **Source file** | `public/assets/images/stratego-pieces.psd` (keep, like other games) |
| **Cell size** | **140 × 140 px** per frame |
| **Layout** | **6 columns × 2 rows**, row-major (frame = `row*6 + col`) |
| **Full image size** | **840 × 280 px** |
| **Frame count** | **12** |
| **Background** | **Transparent** (alpha) — required |
| **Color mode** | RGBA, 8-bit |
Phaser slices it on a fixed 140×140 grid with no padding/margin. Keep every frame
inside its own 140×140 cell; don't bleed art across cell boundaries.
### Cell grid (what goes where)
```
col0 col1 col2 col3 col4 col5
row0 [0 Flag ] [1 Spy ] [2 Scout] [3 Miner] [4 Sgt ] [5 Lt ]
row1 [6 Capt ] [7 Major] [8 Col ] [9 Gen ] [10 Mrshl][11 Bomb]
```
---
## 2. Frame-by-frame contents
The number is the **rank** (also drawn by code in the corner — see §3). "Qty" is
how many of each piece are on a side, for your awareness only — you draw **one**
frame per type regardless of quantity.
| Frame | Unit | Rank | Qty | Role / art suggestion |
|---|---|---|---|---|
| 0 | **Flag** | — | 1 | The objective; capturing it wins. A pennant/flag on a staff. |
| 1 | **Spy** | 1 | 1 | Assassinates the Marshal *when it attacks*. A masked figure / dagger / cloak. |
| 2 | **Scout** | 2 | 8 | Moves any distance in a line. A runner / binoculars / light recon. |
| 3 | **Miner** | 3 | 5 | The only piece that can defuse Bombs. A pickaxe / shovel / sapper. |
| 4 | **Sergeant** | 4 | 4 | Foot soldier. Chevrons (3 stripes). |
| 5 | **Lieutenant** | 5 | 4 | Junior officer. One bar / single pip. |
| 6 | **Captain** | 6 | 4 | Officer. Two bars / pips. |
| 7 | **Major** | 7 | 3 | Field officer. Oak leaf / higher insignia. |
| 8 | **Colonel** | 8 | 2 | Senior officer. Eagle / heavier insignia. |
| 9 | **General** | 9 | 1 | High command. Stars (e.g. 34 stars). |
| 10 | **Marshal** | 10 | 1 | Highest rank. Top insignia — baton / 5 stars / crown. |
| 11 | **Bomb** | — | 6 | Immovable; destroys any attacker except a Miner. A round bomb with fuse. |
A military-insignia style (chevrons for enlisted, bars/leaves/eagles/stars climbing
the officer ranks) reads instantly and gives a natural visual hierarchy, but any
consistent set works — these are suggestions, not requirements.
---
## 3. How the art is rendered (important)
The engine draws each face-up piece in **three stacked layers**:
```
┌─────────────────────┐
│ ① colored MEDALLION │ ← code draws this: rounded square in the
│ ┌───────────────┐ │ owner's color (red = you, blue = AI),
│ ②│ YOUR ART here │ │ with a lighter top bevel.
│ │ (this sheet) │ │ ← ② your frame, centered, ~78% of the tile.
│ └───────────────┘ │
│ ③ rank # top-left │ ← code draws the white number/letter on top.
└─────────────────────┘
```
So your art only needs to supply the **character/emblem** — not the body, not the
number, not the player color.
**On-screen sizes** (so you know how much detail survives):
- The colored body renders at ~**76 px** square.
- Your frame is scaled to ~**78% → ~59 px** square, centered on the body.
- The rank number renders at ~**17 px**, white with a black outline, in the
**upper-left corner** of the body.
**Practical consequences:**
- **Reserve the upper-left corner.** Keep roughly the **top-left ~30%** of each
frame low-detail / lower-contrast so the white corner number stays legible over
it. (Frames 0 Flag and 11 Bomb also get a corner letter — `F` and `B`.)
- **Design for ~60 px.** Final display is small, so favor a bold silhouette and
clear shapes over fine linework. Build at 140 px native; don't expect hairline
detail to read.
- **Center the subject** within the cell with a little padding (aim for ~1015 px
of breathing room to the cell edges so nothing clips when scaled).
---
## 4. Color & contrast — must read on BOTH colors
This is a **single neutral set**: the *same* 12 frames are drawn on red pieces and
on blue pieces (the body color is what distinguishes ownership). Therefore:
- **Do not** bake red or blue into the character — it'll disappear on the matching
body. Favor **light/neutral fills** (creams, golds, steel, parchment, off-white)
with **dark outlines**.
- Owner body colors for contrast reference: **red `#c2402f`**, **blue `#3f6fd0`**.
Your art must stay legible sitting on top of each.
- A consistent **dark outline / drop edge** around the emblem helps it pop on both
backgrounds — this is the single most useful trick.
---
## 5. What NOT to include
- ❌ **No piece body / frame / medallion** — code draws the colored square.
- ❌ **No rank numbers or `B`/`F` letters** — code draws those on top (adding your
own would double them up).
- ❌ **No red/blue ownership coloring** — same art serves both sides.
- ❌ **No face-down "card back"** — the engine draws the hidden-piece back itself;
this sheet is only ever shown for revealed/owned pieces.
- ❌ **No background fill** — transparency only.
---
## 6. Export checklist
- [ ] Canvas exactly **840 × 280 px**, 12 cells on a **6×2** grid of **140×140**.
- [ ] All 12 frames filled, in the order in §12 (Flag at 0 … Bomb at 11).
- [ ] **Transparent** background; no stray pixels outside subjects.
- [ ] Each subject centered in its cell with padding; **top-left corner kept clear**.
- [ ] Neutral/light palette + dark outline; checked on both `#c2402f` and `#3f6fd0`.
- [ ] Export `stratego-pieces.png` to `public/assets/images/`.
- [ ] Save the layered `stratego-pieces.psd` alongside it.
Until this file exists, the game is fully playable — the engine falls back to
code-drawn glyphs (a flag, a bomb, or the big rank number + unit name). Drop the
PNG in and it's picked up automatically on next load (it's already wired into the
preloader and `textures.exists` check).
---
## 7. Second deliverable — the menu icon (frame 46)
Separate file, separate sheet: the game-select menu shows an icon per game from
`public/assets/images/game-icons.png` (and its `.psd`), sliced at **44 × 44 px**.
Stratego is registered as **`iconFrame: 46`**, so it needs a 44×44 icon placed in
that frame slot (row-major; with a 44px grid that's the 47th cell). Suggested
subject: a Stratego piece silhouette or crossed flag/bomb motif that reads at small
size. This is optional for gameplay but needed for the game to show a proper tile
in the menu.

View File

@ -0,0 +1,50 @@
# Stratego Spritesheet Spec
**File:** `public/assets/images/stratego-pieces.png`
**Cell size:** 140 × 140 px
**Layout:** 6 columns × 2 rows, row-major (frame = row × 6 + col)
**Total frames:** 12
**Transparency:** required — each frame is a single transparent-background
character/emblem that the engine overlays on a coloured piece body it draws with
Phaser Graphics.
## How it's used
For every face-up piece the engine draws, in this order:
1. A rounded "medallion" body in the owner's colour (red = you, blue = AI) — code.
2. **This spritesheet frame**, scaled to ~78% of the tile, centred on the body.
3. The rank number in the **upper-left corner** (`1``10`, or `B`/`F`) — code.
So the art only needs to supply the **character/emblem** for each unit type. Keep
the subject roughly centred with a little headroom (the corner number sits over the
top-left). Design the art to read on **both** a red and a blue body — favour light/
neutral fills with dark outlines rather than red or blue dominant colours.
Face-down enemy pieces never use this sheet (the engine draws a generic back), so no
"card back" frame is needed here.
## Frame table
| Frame | Unit | Rank | Notes |
|-------|-------------|------|-------|
| 0 | Flag | — | Objective; immovable. Corner label `F`. |
| 1 | Spy | 1 | Defeats the Marshal when it attacks. |
| 2 | Scout | 2 | Moves any distance in a line. |
| 3 | Miner | 3 | Defuses Bombs. A pick/shovel reads well. |
| 4 | Sergeant | 4 | |
| 5 | Lieutenant | 5 | |
| 6 | Captain | 6 | |
| 7 | Major | 7 | |
| 8 | Colonel | 8 | |
| 9 | General | 9 | |
| 10 | Marshal | 10 | Highest rank. |
| 11 | Bomb | — | Immovable; destroys any attacker but a Miner. Corner label `B`. |
## Notes
- Until this PNG exists the game is fully playable: the engine falls back to a
code-drawn glyph (a flag, a bomb, or the big rank number with the unit name).
- A matching `stratego-pieces.psd` source is expected alongside the PNG, as with the
other games' art.
- The game-menu icon for Stratego is `iconFrame: 46` in `game-icons.png` (authored
separately, like every other game's icon).

View File

@ -56,6 +56,7 @@ import TectonicGame from './games/tectonic/TectonicGame.js';
import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
import FarkelGame from './games/farkel/FarkelGame.js';
import StrategoGame from './games/stratego/StrategoGame.js';
const config = {
type: Phaser.AUTO,
@ -125,6 +126,7 @@ const config = {
LabyrinthGame,
VideoPokerGame,
FarkelGame,
StrategoGame,
],
};

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

View File

@ -384,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

View File

@ -95,6 +95,11 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.mp3');
this.load.audio('sfx-battleship-launch', '/assets/fx/battleship-launch.mp3');
this.load.audio('sfx-victory-short', '/assets/fx/victory-short.mp3');
this.load.audio('sfx-scifi-launch', '/assets/fx/scifi-launch.mp3');
this.load.audio('sfx-scifi-explode', '/assets/fx/scifi-explode.mp3');
this.load.audio('sfx-scifi-riser', '/assets/fx/scifi-riser.mp3');
this.load.audio('sfx-scifi-reveal', '/assets/fx/scifi-reveal.mp3');
this.load.audio('sfx-scifi-woosh', '/assets/fx/scifi-woosh.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
@ -124,6 +129,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('labyrinth-tiles', '/assets/images/labyrinth-tiles.png', { frameWidth: 200, frameHeight: 200 });
this.load.spritesheet('labyrinth-treasures', '/assets/images/labyrinth-treasures.png', { frameWidth: 100, frameHeight: 100 });
this.load.spritesheet('labyrinth-cards', '/assets/images/labyrinth-cards.png', { frameWidth: 270, frameHeight: 390 });
// Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb),
// 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent.
this.load.spritesheet('stratego-pieces', '/assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 });
}
async create() {

View File

@ -27,6 +27,11 @@ export const SFX = {
MASTERMIND_MATCH: 'sfx-mastermind-match',
MASTERMIND_CALCULATE: 'sfx-mastermind-calculate',
VICTORY_SHORT: 'sfx-victory-short',
SCIFI_LAUNCH: 'sfx-scifi-launch',
SCIFI_EXPLODE: 'sfx-scifi-explode',
SCIFI_RISER: 'sfx-scifi-riser',
SCIFI_REVEAL: 'sfx-scifi-reveal',
SCIFI_WOOSH: 'sfx-scifi-woosh',
};
export function playSound(scene, key) {
@ -41,3 +46,44 @@ export function playChipBet(scene) {
_chipBetSound = scene.sound.add(SFX.CHIP_BET);
_chipBetSound.play();
}
// Each plays at most one simultaneous instance (tie battles would otherwise double up).
let _scifiLaunchSound = null;
export function playScifiLaunch(scene) {
if (_scifiLaunchSound?.isPlaying) return;
_scifiLaunchSound?.destroy();
_scifiLaunchSound = scene.sound.add(SFX.SCIFI_LAUNCH);
_scifiLaunchSound.play();
}
let _scifiExplodeSound = null;
export function playScifiExplode(scene) {
if (_scifiExplodeSound?.isPlaying) return;
_scifiExplodeSound?.destroy();
_scifiExplodeSound = scene.sound.add(SFX.SCIFI_EXPLODE);
_scifiExplodeSound.play();
}
let _scifiRiserSound = null;
export function playScifiRiser(scene) {
if (_scifiRiserSound?.isPlaying) return;
_scifiRiserSound?.destroy();
_scifiRiserSound = scene.sound.add(SFX.SCIFI_RISER);
_scifiRiserSound.play();
}
let _scifiRevealSound = null;
export function playScifiReveal(scene) {
if (_scifiRevealSound?.isPlaying) return;
_scifiRevealSound?.destroy();
_scifiRevealSound = scene.sound.add(SFX.SCIFI_REVEAL);
_scifiRevealSound.play();
}
let _scifiWooshSound = null;
export function playScifiWoosh(scene) {
if (_scifiWooshSound?.isPlaying) return;
_scifiWooshSound?.destroy();
_scifiWooshSound = scene.sound.add(SFX.SCIFI_WOOSH);
_scifiWooshSound.play();
}

View File

@ -71,3 +71,4 @@ registerGame({ slug: 'splendor', name: 'Splendor', category: '
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 });
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });
registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });