fertig-classic-games/src/games/stratego/StrategoGame.js

928 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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];
}
}