diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af7862b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "spellright.language": [ + "en-US-10-1." + ], + "spellright.documentTypes": [ + "markdown", + "latex", + "plaintext" + ] +} \ No newline at end of file diff --git a/public/assets/fx/scifi-explode.mp3 b/public/assets/fx/scifi-explode.mp3 new file mode 100644 index 0000000..570166c Binary files /dev/null and b/public/assets/fx/scifi-explode.mp3 differ diff --git a/public/assets/fx/scifi-launch.mp3 b/public/assets/fx/scifi-launch.mp3 new file mode 100644 index 0000000..2ec84e6 Binary files /dev/null and b/public/assets/fx/scifi-launch.mp3 differ diff --git a/public/assets/fx/scifi-reveal.mp3 b/public/assets/fx/scifi-reveal.mp3 new file mode 100644 index 0000000..b1b2624 Binary files /dev/null and b/public/assets/fx/scifi-reveal.mp3 differ diff --git a/public/assets/fx/scifi-riser.mp3 b/public/assets/fx/scifi-riser.mp3 new file mode 100644 index 0000000..e31bbc6 Binary files /dev/null and b/public/assets/fx/scifi-riser.mp3 differ diff --git a/public/assets/fx/scifi-woosh.mp3 b/public/assets/fx/scifi-woosh.mp3 new file mode 100644 index 0000000..867203a Binary files /dev/null and b/public/assets/fx/scifi-woosh.mp3 differ diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 90f7edd..2e48c98 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 3bdc0cf..0654719 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/images/stratego-pieces.png b/public/assets/images/stratego-pieces.png new file mode 100644 index 0000000..24aa276 Binary files /dev/null and b/public/assets/images/stratego-pieces.png differ diff --git a/public/assets/images/stratego-pieces.psd b/public/assets/images/stratego-pieces.psd new file mode 100644 index 0000000..cdc8e69 Binary files /dev/null and b/public/assets/images/stratego-pieces.psd differ diff --git a/public/src/games/stratego/StrategoAI.js b/public/src/games/stratego/StrategoAI.js new file mode 100644 index 0000000..d18d461 --- /dev/null +++ b/public/src/games/stratego/StrategoAI.js @@ -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 1–5 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 }; +} diff --git a/public/src/games/stratego/StrategoData.js b/public/src/games/stratego/StrategoData.js new file mode 100644 index 0000000..5ecb762 --- /dev/null +++ b/public/src/games/stratego/StrategoData.js @@ -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 (4–5). 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); +} diff --git a/public/src/games/stratego/StrategoGame.js b/public/src/games/stratego/StrategoGame.js new file mode 100644 index 0000000..cc82977 --- /dev/null +++ b/public/src/games/stratego/StrategoGame.js @@ -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]; + } +} diff --git a/public/src/games/stratego/StrategoLogic.js b/public/src/games/stratego/StrategoLogic.js new file mode 100644 index 0000000..adc6d86 --- /dev/null +++ b/public/src/games/stratego/StrategoLogic.js @@ -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. +// +// 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 6–9): front=6 … back=9, so + // the natural order is already front→back. Seat 1 (top, rows 0–3): 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; +} diff --git a/public/src/games/stratego/needed_sprites.md b/public/src/games/stratego/needed_sprites.md new file mode 100644 index 0000000..abe4085 --- /dev/null +++ b/public/src/games/stratego/needed_sprites.md @@ -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. 3–4 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 ~10–15 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 §1–2 (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. diff --git a/public/src/games/stratego/sprite.md b/public/src/games/stratego/sprite.md new file mode 100644 index 0000000..9a2c8e1 --- /dev/null +++ b/public/src/games/stratego/sprite.md @@ -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). diff --git a/public/src/main.js b/public/src/main.js index 0505953..b4b8cce 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index cd803f9..ae3a0f1 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame' }; + 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, diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 95b47b5..755cdf6 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -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 1–5 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'); diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index f17469d..465de1a 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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() { diff --git a/public/src/ui/Sounds.js b/public/src/ui/Sounds.js index 39eff6d..0cf8f31 100644 --- a/public/src/ui/Sounds.js +++ b/public/src/ui/Sounds.js @@ -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(); +} diff --git a/server/games/registry.js b/server/games/registry.js index a2dace4..aaae9b7 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 });