928 lines
40 KiB
JavaScript
928 lines
40 KiB
JavaScript
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];
|
||
}
|
||
}
|