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:
parent
701b4f75e6
commit
19898bf157
|
|
@ -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.
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue