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; // Portrait emotion: AI upset when it loses a high-value piece; happy when it captures one. const REACT_RANK = 6; const aiLosesPiece = res === 'both' || (res === 'attacker' && attackerIsHuman) || (res === 'defender' && !attackerIsHuman); const aiCapturesPiece = (res === 'attacker' && !attackerIsHuman) || (res === 'defender' && attackerIsHuman); const triggerEmotion = () => { const ctrl = this.portraits[this.aiSeat]; if (aiLosesPiece && aiPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('upset'); else if (aiCapturesPiece && humanPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('happy'); }; 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. let emotionFired = false; fireShot(humanStageX, STAGE_CY, aiStageX, STAGE_CY, () => { this._spawnExplosions(aiStageX, STAGE_CY); fadeOut(aiCont, aiRankTxt); if (!emotionFired) { emotionFired = true; triggerEmotion(); } }); fireShot(aiStageX, STAGE_CY, humanStageX, STAGE_CY, () => { this._spawnExplosions(humanStageX, STAGE_CY); fadeOut(humanCont, humanRankTxt); if (!emotionFired) { emotionFired = true; triggerEmotion(); } }); // 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); triggerEmotion(); 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]; } }