1003 lines
44 KiB
JavaScript
1003 lines
44 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 { api } from '../../services/api.js';
|
||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||
import {
|
||
TERRITORIES, NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
|
||
BOARD_W, BOARD_H, PLAYER_COLORS, PLAYER_COLOR_HEX, setValue,
|
||
CARD_INFANTRY, CARD_CAVALRY, CARD_ARTILLERY, CARD_WILD,
|
||
} from './RiskData.js';
|
||
import {
|
||
createInitialState, reinforcementCount, placeArmies, tradeCards,
|
||
resolveAttack, advanceArmies, endAttack, fortify, endTurn,
|
||
canAttack, connectedOwned, hasValidSet, mustTradeCards,
|
||
territoriesOf, countTerritories, continentBonus, ownsContinent, isGameOver,
|
||
} from './RiskLogic.js';
|
||
import {
|
||
chooseTrade, planReinforcements, chooseAttack, chooseAdvance, chooseFortify,
|
||
nextThinkDelay,
|
||
} from './RiskAI.js';
|
||
|
||
// ── Layout ──────────────────────────────────────────────────────────────────
|
||
const PANEL_X = 1560;
|
||
const PANEL_W = GAME_WIDTH - PANEL_X - 16; // ~344
|
||
const PANEL_Y = 112; // top of panel (12 + 100)
|
||
const PANEL_H = GAME_HEIGHT - 224; // height (GAME_HEIGHT - 24 - 200)
|
||
const PORTRAIT_R = 37; // portrait radius (22 × 1.7)
|
||
const PORTRAIT_CX = PANEL_X + 58; // portrait centre x
|
||
const PORTRAIT_ROW = 102; // row spacing (60 × 1.7)
|
||
const MAP_LEFT = 16, MAP_TOP = 16;
|
||
const MAP_AREA_W = PANEL_X - MAP_LEFT - 16; // ~1528
|
||
const MAP_AREA_H = GAME_HEIGHT - MAP_TOP - 16; // ~1048
|
||
const MAP_SCALE = Math.min(MAP_AREA_W / BOARD_W, MAP_AREA_H / BOARD_H);
|
||
const DISP_W = BOARD_W * MAP_SCALE, DISP_H = BOARD_H * MAP_SCALE;
|
||
const MAP_X = MAP_LEFT + (MAP_AREA_W - DISP_W) / 2;
|
||
const MAP_Y = MAP_TOP + (MAP_AREA_H - DISP_H) / 2;
|
||
const BADGE_R = 17;
|
||
|
||
const DEPTH = {
|
||
bg: 0, board: 2, link: 3, highlight: 4, badge: 8, label: 9,
|
||
panel: 20, ui: 25, dice: 40, popup: 50, banner: 90,
|
||
};
|
||
|
||
const CARD_GLYPH = {
|
||
[CARD_INFANTRY]: '🛡', [CARD_CAVALRY]: '🐎', [CARD_ARTILLERY]: '🎯', [CARD_WILD]: '★',
|
||
};
|
||
const CARD_LABEL = {
|
||
[CARD_INFANTRY]: 'Infantry', [CARD_CAVALRY]: 'Cavalry', [CARD_ARTILLERY]: 'Artillery', [CARD_WILD]: 'Wild',
|
||
};
|
||
|
||
export default class RiskGame extends Phaser.Scene {
|
||
constructor() { super('RiskGame'); }
|
||
|
||
init(data) {
|
||
this.gameDef = data.game ?? { slug: 'risk', name: 'Risk' };
|
||
this.opponents = data.opponents ?? [];
|
||
this.playfield = data.playfield ?? null;
|
||
this.humanSeat = 0;
|
||
this.gs = null;
|
||
this.busy = false;
|
||
this.selFrom = null; // selected source territory (attack/fortify)
|
||
this.hoverTerr = null;
|
||
this.dyn = []; // per-render disposables
|
||
this.zones = []; // 42 interactive zones (created once)
|
||
this.portraits = [];
|
||
this.debug = false;
|
||
this.gameOverShown = false;
|
||
}
|
||
|
||
create() {
|
||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* */ }
|
||
|
||
const playerCount = Math.max(2, Math.min(6, 1 + this.opponents.length));
|
||
const names = [];
|
||
const skills = {};
|
||
for (let seat = 0; seat < playerCount; seat++) {
|
||
if (seat === this.humanSeat) { names.push(auth.user?.username ?? 'You'); skills[seat] = 5; }
|
||
else {
|
||
const opp = this.opponents[seat - 1];
|
||
names.push(opp?.name ?? `Player ${seat + 1}`);
|
||
skills[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
|
||
}
|
||
}
|
||
this.gs = createInitialState({ playerCount, names, skills });
|
||
|
||
this.buildBackground();
|
||
this.buildBoard();
|
||
this.buildPanel();
|
||
this.buildPortraits();
|
||
|
||
new Button(this, GAME_WIDTH - 86, GAME_HEIGHT - 30, 'Leave',
|
||
() => this.scene.start('GameMenu'),
|
||
{ variant: 'ghost', width: 140, height: 44, fontSize: 20 }).setDepth(DEPTH.ui);
|
||
|
||
// Debug overlay toggle (territory ids/names) for coordinate tuning.
|
||
this.input.keyboard?.on('keydown-D', () => { this.debug = !this.debug; this.render(); });
|
||
|
||
this.render();
|
||
this.advance();
|
||
}
|
||
|
||
// ── coordinate transform ────────────────────────────────────────────────────
|
||
boardToScreen(x, y) { return { x: MAP_X + x * MAP_SCALE, y: MAP_Y + y * MAP_SCALE }; }
|
||
|
||
// ── static build ────────────────────────────────────────────────────────────
|
||
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 fallback = pf?.fallbackColor
|
||
? parseInt(pf.fallbackColor.replace('#', ''), 16)
|
||
: 0x0a1822;
|
||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, fallback)
|
||
.setDepth(DEPTH.bg);
|
||
}
|
||
// ocean panel behind the map
|
||
const g = this.add.graphics().setDepth(DEPTH.bg + 1);
|
||
g.fillStyle(0x0e2230, 1);
|
||
g.fillRoundedRect(MAP_X - 8, MAP_Y - 8, DISP_W + 16, DISP_H + 16, 10);
|
||
}
|
||
|
||
buildBoard() {
|
||
if (this.textures.exists('risk-board')) {
|
||
this.add.image(MAP_X + DISP_W / 2, MAP_Y + DISP_H / 2, 'risk-board')
|
||
.setDisplaySize(DISP_W, DISP_H).setDepth(DEPTH.board);
|
||
}
|
||
// one interactive zone per territory (zones have a native hit area — no
|
||
// Container-hitbox pitfall).
|
||
const zr = Math.max(26, BADGE_R * 2.4);
|
||
for (const t of TERRITORIES) {
|
||
const p = this.boardToScreen(t.x, t.y);
|
||
const z = this.add.zone(p.x, p.y, zr, zr).setInteractive({ useHandCursor: true }).setDepth(DEPTH.badge + 1);
|
||
z.on('pointerover', () => { this.hoverTerr = t.id; this.render(); });
|
||
z.on('pointerout', () => { if (this.hoverTerr === t.id) this.hoverTerr = null; this.render(); });
|
||
z.on('pointerdown', () => this.onTerritoryClick(t.id));
|
||
this.zones.push(z);
|
||
}
|
||
}
|
||
|
||
buildPanel() {
|
||
const g = this.add.graphics().setDepth(DEPTH.panel);
|
||
g.fillStyle(COLORS.panel, 0.96);
|
||
g.fillRoundedRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 12);
|
||
g.lineStyle(2, COLORS.accent, 0.8);
|
||
g.strokeRoundedRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 12);
|
||
this.add.text(PANEL_X + PANEL_W / 2, PANEL_Y + 28, 'RISK', {
|
||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.panel);
|
||
}
|
||
|
||
buildPortraits() {
|
||
// small portraits stacked under the title for each player
|
||
let y = PANEL_Y + 90;
|
||
for (let seat = 0; seat < this.gs.playerCount; seat++) {
|
||
let portrait = null;
|
||
if (seat === this.humanSeat) portrait = createPlayerPortrait(this, PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, 'RiskGame');
|
||
else portrait = createOpponentPortrait(this, this.opponents[seat - 1], PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, { playIntro: false });
|
||
// colored ring matching the player's piece color
|
||
const ring = this.add.graphics().setDepth(DEPTH.panel + 2);
|
||
ring.lineStyle(7, this.colorOf(seat), 1);
|
||
ring.strokeCircle(PORTRAIT_CX, y, PORTRAIT_R + 4);
|
||
this.portraits.push({ seat, portrait, y });
|
||
y += PORTRAIT_ROW;
|
||
}
|
||
this.panelRowY = y; // first free y under portraits
|
||
}
|
||
|
||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||
colorOf(seat) { return PLAYER_COLORS[seat % PLAYER_COLORS.length]; }
|
||
colorHexOf(seat) { return PLAYER_COLOR_HEX[seat % PLAYER_COLOR_HEX.length]; }
|
||
isHumanTurn() { return this.gs.current === this.humanSeat && !this.busy && !isGameOver(this.gs); }
|
||
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
|
||
|
||
// territories the current selection can act on (attack targets / fortify targets)
|
||
validTargets() {
|
||
if (this.selFrom == null) return new Set();
|
||
const s = this.gs, seat = s.current;
|
||
if (s.phase === 'attack') return new Set(ADJ[this.selFrom].filter((t) => s.owner[t] !== seat));
|
||
if (s.phase === 'fortify') return new Set(connectedOwned(s, seat, this.selFrom));
|
||
return new Set();
|
||
}
|
||
|
||
// ── render ────────────────────────────────────────────────────────────────────
|
||
render() {
|
||
this.dyn.forEach((o) => o.destroy?.());
|
||
this.dyn = [];
|
||
if (!this.gs) return;
|
||
|
||
const hi = this.add.graphics().setDepth(DEPTH.highlight); this.dyn.push(hi);
|
||
const targets = this.validTargets();
|
||
const s = this.gs;
|
||
|
||
// selection + target rings
|
||
for (const t of TERRITORIES) {
|
||
const p = this.boardToScreen(t.x, t.y);
|
||
if (this.selFrom === t.id) {
|
||
hi.lineStyle(4, COLORS.gold, 1); hi.strokeCircle(p.x, p.y, BADGE_R + 8);
|
||
} else if (targets.has(t.id)) {
|
||
const col = s.phase === 'attack' ? COLORS.danger : 0x4fd06a;
|
||
hi.lineStyle(3, col, 0.95); hi.strokeCircle(p.x, p.y, BADGE_R + 6);
|
||
}
|
||
}
|
||
|
||
// army badges
|
||
for (const t of TERRITORIES) {
|
||
const p = this.boardToScreen(t.x, t.y);
|
||
const owner = s.owner[t.id];
|
||
const g = this.add.graphics().setDepth(DEPTH.badge); this.dyn.push(g);
|
||
g.fillStyle(0x000000, 0.35); g.fillCircle(p.x + 1, p.y + 2, BADGE_R);
|
||
g.fillStyle(this.colorOf(owner), 1); g.fillCircle(p.x, p.y, BADGE_R);
|
||
g.lineStyle(2, 0xffffff, 0.9); g.strokeCircle(p.x, p.y, BADGE_R);
|
||
const txt = this.add.text(p.x, p.y, String(s.armies[t.id]), {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: '#ffffff',
|
||
}).setOrigin(0.5).setDepth(DEPTH.label); this.dyn.push(txt);
|
||
}
|
||
|
||
// hovered / selected territory name label
|
||
const nameId = this.hoverTerr ?? this.selFrom;
|
||
if (nameId != null) {
|
||
const t = TERRITORIES[nameId];
|
||
const p = this.boardToScreen(t.x, t.y);
|
||
const lbl = this.add.text(p.x, p.y - BADGE_R - 12, t.name, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
|
||
backgroundColor: '#000000aa', padding: { x: 5, y: 2 },
|
||
}).setOrigin(0.5, 1).setDepth(DEPTH.label); this.dyn.push(lbl);
|
||
}
|
||
|
||
// debug ids
|
||
if (this.debug) {
|
||
for (const t of TERRITORIES) {
|
||
const p = this.boardToScreen(t.x, t.y);
|
||
const d = this.add.text(p.x, p.y + BADGE_R + 2, `${t.id}`, {
|
||
fontFamily: 'monospace', fontSize: '11px', color: '#ffff66',
|
||
}).setOrigin(0.5, 0).setDepth(DEPTH.label); this.dyn.push(d);
|
||
}
|
||
}
|
||
|
||
this.renderPanel();
|
||
}
|
||
|
||
renderPanel() {
|
||
const s = this.gs;
|
||
const lx = PANEL_X + 16, rx = PANEL_X + PANEL_W - 16;
|
||
const textX = PORTRAIT_CX + PORTRAIT_R + 17; // left edge of text column
|
||
|
||
// per-player rows — name + stats inline with portrait
|
||
for (let seat = 0; seat < s.playerCount; seat++) {
|
||
const p = s.players[seat];
|
||
const isCur = seat === s.current && !isGameOver(s);
|
||
const terr = countTerritories(s, seat);
|
||
let army = 0; for (let t = 0; t < NUM_TERRITORIES; t++) if (s.owner[t] === seat) army += s.armies[t];
|
||
const py = this.portraits[seat].y;
|
||
const name = p.alive ? p.name : `${p.name} (out)`;
|
||
const col = p.alive ? this.colorHexOf(seat) : COLORS.mutedHex;
|
||
|
||
// gold outer ring on portrait for the active player
|
||
if (isCur) {
|
||
const glow = this.add.graphics().setDepth(DEPTH.panel + 3); this.dyn.push(glow);
|
||
glow.lineStyle(5, COLORS.gold, 1); glow.strokeCircle(PORTRAIT_CX, py, PORTRAIT_R + 9);
|
||
}
|
||
|
||
const t1 = this.add.text(textX, py - 15, name, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '27px', color: isCur ? COLORS.goldHex : col,
|
||
}).setOrigin(0, 0.5).setDepth(DEPTH.panel + 1); this.dyn.push(t1);
|
||
const t2 = this.add.text(textX, py + 17, `${terr}⬡ ${army}⚔`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0.5).setDepth(DEPTH.panel + 1); this.dyn.push(t2);
|
||
}
|
||
|
||
let y = this.panelRowY + 10;
|
||
const sep = this.add.graphics().setDepth(DEPTH.panel + 1); this.dyn.push(sep);
|
||
sep.lineStyle(1, COLORS.accent, 0.4); sep.lineBetween(lx, y, rx, y);
|
||
y += 12;
|
||
|
||
// current phase + instructions
|
||
const phaseName = isGameOver(s) ? 'Game Over' :
|
||
({ reinforce: 'Reinforce', attack: 'Attack', fortify: 'Fortify' }[s.phase] ?? s.phase);
|
||
const who = s.current === this.humanSeat ? 'Your' : `${s.players[s.current].name}'s`;
|
||
const ph = this.add.text(lx, y, `${who} turn — ${phaseName}`, {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.textHex, wordWrap: { width: PANEL_W - 32 },
|
||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(ph);
|
||
y += 34;
|
||
|
||
if (s.phase === 'reinforce' && !isGameOver(s)) {
|
||
const info = this.add.text(lx, y, `Armies to place: ${s.reinforcements}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(info);
|
||
y += 28;
|
||
}
|
||
|
||
// current player's card count
|
||
const cardN = s.players[s.current].cards.length;
|
||
const cc = this.add.text(lx, y, `Cards: ${cardN}` + (s.current === this.humanSeat ? '' : ''), {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(cc);
|
||
y += 30;
|
||
|
||
this.panelControlsY = y;
|
||
this.renderControls();
|
||
}
|
||
|
||
renderControls() {
|
||
if (!this.isHumanTurn()) return;
|
||
const s = this.gs;
|
||
const cx = PANEL_X + PANEL_W / 2;
|
||
const panelBottom = PANEL_Y + PANEL_H;
|
||
const hintY = panelBottom - 52;
|
||
const btnY = hintY - 52;
|
||
const btn2Y = btnY - 56;
|
||
|
||
const mk = (label, fn, y, opts = {}) => {
|
||
const b = new Button(this, cx, y, label, fn,
|
||
{ width: PANEL_W - 40, height: 46, fontSize: 20, ...opts }).setDepth(DEPTH.ui);
|
||
this.dyn.push(b);
|
||
return b;
|
||
};
|
||
const mkHint = (text, y) => {
|
||
const h = this.add.text(cx, y, text, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||
align: 'center', wordWrap: { width: PANEL_W - 36 },
|
||
}).setOrigin(0.5, 0.5).setDepth(DEPTH.ui); this.dyn.push(h);
|
||
};
|
||
|
||
if (s.phase === 'reinforce') {
|
||
const human = s.players[this.humanSeat];
|
||
const set = hasValidSet(human.cards);
|
||
if (set) mk(mustTradeCards(s, this.humanSeat) ? 'Trade Cards (required)' : 'Trade Cards', () => this.openTradeModal(), btnY);
|
||
mkHint('Click your territories to place armies', hintY);
|
||
} else if (s.phase === 'attack') {
|
||
mk('End Attack ▸', () => { this.selFrom = null; this.gs = endAttack(this.gs); this.render(); this.advance(); }, btnY);
|
||
mkHint(this.selFrom == null
|
||
? 'Click one of your territories (2+ armies) to attack from'
|
||
: 'Click a red enemy territory to attack', hintY);
|
||
} else if (s.phase === 'fortify') {
|
||
mk('Skip / End Turn ▸', () => { this.selFrom = null; this.gs = endTurn(this.gs); this.render(); this.advance(); }, btnY);
|
||
mkHint(this.selFrom == null
|
||
? 'Optionally fortify: click a source territory'
|
||
: 'Click a green connected territory to move armies', hintY);
|
||
}
|
||
}
|
||
|
||
// ── human input ───────────────────────────────────────────────────────────────
|
||
onTerritoryClick(id) {
|
||
if (!this.isHumanTurn()) return;
|
||
const s = this.gs, seat = s.current;
|
||
|
||
if (s.phase === 'reinforce') {
|
||
if (s.owner[id] !== seat) return;
|
||
if (mustTradeCards(s, seat)) { this.openTradeModal(); return; }
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
this.gs = placeArmies(s, id, 1);
|
||
this.render();
|
||
if (this.gs.phase !== 'reinforce') this.advance(); // pool emptied → attack
|
||
return;
|
||
}
|
||
|
||
if (s.phase === 'attack') {
|
||
if (s.owner[id] === seat && s.armies[id] >= 2 && ADJ[id].some((t) => s.owner[t] !== seat)) {
|
||
this.selFrom = id; this.render(); return;
|
||
}
|
||
if (this.selFrom != null && canAttack(s, seat, this.selFrom, id)) { this.doHumanAttack(this.selFrom, id); return; }
|
||
this.selFrom = null; this.render(); return;
|
||
}
|
||
|
||
if (s.phase === 'fortify') {
|
||
if (this.selFrom == null) {
|
||
if (s.owner[id] === seat && s.armies[id] >= 2 && connectedOwned(s, seat, id).length > 0) {
|
||
this.selFrom = id; this.render();
|
||
}
|
||
return;
|
||
}
|
||
if (id === this.selFrom) { this.selFrom = null; this.render(); return; }
|
||
if (s.owner[id] === seat && connectedOwned(s, seat, this.selFrom).includes(id)) {
|
||
const from = this.selFrom, to = id;
|
||
this.openMoveModal(s.armies[from] - 1, s.armies[from] - 1, 1, (n) => {
|
||
this.selFrom = null;
|
||
this.gs = fortify(this.gs, from, to, n);
|
||
this.render(); this.advance();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async doHumanAttack(from, to) {
|
||
this.busy = true;
|
||
const before = this.gs;
|
||
this.gs = resolveAttack(this.gs, from, to);
|
||
await this.animateBattle(this.gs.lastBattle);
|
||
this.render();
|
||
|
||
if (this.gs.pendingConquest) {
|
||
const pc = this.gs.pendingConquest;
|
||
this.busy = false;
|
||
this.openMoveModal(pc.minMove, pc.maxMove, pc.minMove, async (n) => {
|
||
this.busy = true;
|
||
this.gs = advanceArmies(this.gs, n);
|
||
// keep attacking from same territory if still able
|
||
if (this.gs.owner[from] === before.current && this.gs.armies[from] < 2) this.selFrom = null;
|
||
this.render();
|
||
await this.animateConquest(from, to, n, this.humanSeat);
|
||
this.busy = false;
|
||
this.render();
|
||
if (isGameOver(this.gs)) this.showGameOver();
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (this.gs.armies[from] < 2) this.selFrom = null;
|
||
this.busy = false;
|
||
this.render();
|
||
if (isGameOver(this.gs)) this.showGameOver();
|
||
}
|
||
|
||
// ── trade modal ───────────────────────────────────────────────────────────────
|
||
openTradeModal() {
|
||
const seat = this.humanSeat;
|
||
const cards = this.gs.players[seat].cards;
|
||
const objs = [];
|
||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||
.setInteractive().setDepth(DEPTH.popup); objs.push(overlay);
|
||
const W = 720, H = 420, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup + 1); objs.push(panel);
|
||
panel.fillStyle(COLORS.panel, 1); panel.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||
panel.lineStyle(2, COLORS.accent, 1); panel.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||
objs.push(this.add.text(cx, cy - H / 2 + 32, `Trade a Set (next set = ${setValue(this.gs.setsCashed)} armies)`, {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2));
|
||
|
||
const selected = new Set();
|
||
const chipObjs = [];
|
||
let tradeBtn = null;
|
||
|
||
const refreshTradeBtn = () => {
|
||
const sel = [...selected].map((i) => cards[i]);
|
||
const valid = sel.length === 3 && hasValidSet(sel);
|
||
tradeBtn.setAlpha(valid ? 1 : 0.4);
|
||
tradeBtn.disabledForTrade = !valid;
|
||
};
|
||
|
||
const drawChips = () => {
|
||
chipObjs.forEach((o) => o.destroy());
|
||
chipObjs.length = 0;
|
||
const per = 5;
|
||
const cw = 120, ch = 150, gap = 14;
|
||
cards.forEach((card, i) => {
|
||
const row = Math.floor(i / per), colI = i % per;
|
||
const rowCount = Math.min(per, cards.length - row * per);
|
||
const startX = cx - ((rowCount * cw + (rowCount - 1) * gap) / 2) + cw / 2;
|
||
const x = startX + colI * (cw + gap);
|
||
const yy = cy - 40 + row * (ch + gap);
|
||
const g = this.add.graphics().setDepth(DEPTH.popup + 2);
|
||
const on = selected.has(i);
|
||
g.fillStyle(on ? COLORS.gold : 0x2a2418, 1); g.fillRoundedRect(x - cw / 2, yy - ch / 2, cw, ch, 10);
|
||
g.lineStyle(2, on ? 0xffffff : COLORS.accent, 0.9); g.strokeRoundedRect(x - cw / 2, yy - ch / 2, cw, ch, 10);
|
||
const glyph = this.add.text(x, yy - 30, CARD_GLYPH[card.type], { fontSize: '40px' }).setOrigin(0.5).setDepth(DEPTH.popup + 3);
|
||
const tl = this.add.text(x, yy + 16, CARD_LABEL[card.type], {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: on ? COLORS.textDarkHex : COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 3);
|
||
const tn = this.add.text(x, yy + 42, card.territory != null ? TERRITORIES[card.territory].name : '—', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '12px', color: on ? COLORS.textDarkHex : COLORS.mutedHex,
|
||
wordWrap: { width: cw - 12 }, align: 'center',
|
||
}).setOrigin(0.5, 0).setDepth(DEPTH.popup + 3);
|
||
const z = this.add.zone(x, yy, cw, ch).setInteractive({ useHandCursor: true }).setDepth(DEPTH.popup + 4);
|
||
z.on('pointerdown', () => {
|
||
if (selected.has(i)) selected.delete(i);
|
||
else { if (selected.size >= 3) selected.delete([...selected][0]); selected.add(i); }
|
||
drawChips(); refreshTradeBtn();
|
||
});
|
||
chipObjs.push(g, glyph, tl, tn, z);
|
||
});
|
||
};
|
||
|
||
const close = () => { objs.forEach((o) => o.destroy()); chipObjs.forEach((o) => o.destroy()); this.render(); };
|
||
|
||
tradeBtn = new Button(this, cx - 130, cy + H / 2 - 40, 'Trade', () => {
|
||
if (tradeBtn.disabledForTrade) return;
|
||
const ids = [...selected].map((i) => cards[i].id);
|
||
this.gs = tradeCards(this.gs, ids);
|
||
playSound(this, SFX.COINS);
|
||
close();
|
||
if (!mustTradeCards(this.gs, seat)) { /* allow closing */ }
|
||
else { this.openTradeModal(); return; } // still forced → reopen
|
||
this.render();
|
||
}, { width: 220, height: 48, fontSize: 22 }).setDepth(DEPTH.popup + 3);
|
||
objs.push(tradeBtn);
|
||
|
||
if (!mustTradeCards(this.gs, seat)) {
|
||
const closeBtn = new Button(this, cx + 130, cy + H / 2 - 40, 'Close', close,
|
||
{ width: 220, height: 48, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.popup + 3);
|
||
objs.push(closeBtn);
|
||
}
|
||
|
||
drawChips(); refreshTradeBtn();
|
||
}
|
||
|
||
// ── move-amount modal (advance after conquest / fortify) ────────────────────────
|
||
openMoveModal(min, max, initial, onConfirm) {
|
||
if (max <= min) { onConfirm(min); return; }
|
||
let val = Math.max(min, Math.min(initial, max));
|
||
const objs = [];
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
const W = 640, H = 240;
|
||
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive().setDepth(DEPTH.popup); objs.push(overlay);
|
||
const panel = this.add.graphics().setDepth(DEPTH.popup + 1); objs.push(panel);
|
||
panel.fillStyle(COLORS.panel, 1); panel.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||
panel.lineStyle(2, COLORS.accent, 1); panel.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||
objs.push(this.add.text(cx, cy - H / 2 + 30, 'Move armies', {
|
||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2));
|
||
const valTxt = this.add.text(cx, cy - 10, String(val), {
|
||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(valTxt);
|
||
const rng = this.add.text(cx, cy + 34, `min ${min} · max ${max}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(rng);
|
||
const upd = () => valTxt.setText(String(val));
|
||
const btnMin = new Button(this, cx - 230, cy - 4, 'Min', () => { val = min; upd(); }, { width: 80, height: 64, fontSize: 20, variant: 'ghost' }).setDepth(DEPTH.popup + 2); objs.push(btnMin);
|
||
const minus = new Button(this, cx - 130, cy - 4, '−', () => { val = Math.max(min, val - 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(minus);
|
||
const plus = new Button(this, cx + 130, cy - 4, '+', () => { val = Math.min(max, val + 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(plus);
|
||
const btnMax = new Button(this, cx + 230, cy - 4, 'Max', () => { val = max; upd(); }, { width: 80, height: 64, fontSize: 20, variant: 'ghost' }).setDepth(DEPTH.popup + 2); objs.push(btnMax);
|
||
const ok = new Button(this, cx, cy + H / 2 - 34, 'Confirm', () => { objs.forEach((o) => o.destroy()); onConfirm(val); }, { width: 260, height: 46, fontSize: 22 }).setDepth(DEPTH.popup + 2); objs.push(ok);
|
||
}
|
||
|
||
// ── combat animation ────────────────────────────────────────────────────────────
|
||
async animateAttackArrow(b) {
|
||
const seat = this.gs.owner[b.from];
|
||
const color = this.colorOf(seat);
|
||
const from = this.boardToScreen(TERRITORIES[b.from].x, TERRITORIES[b.from].y);
|
||
const to = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y);
|
||
|
||
// Quadratic bezier control point arching upward (negative-Y) from the midpoint
|
||
const dx = to.x - from.x, dy = to.y - from.y;
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
let perpX = -dy / len, perpY = dx / len;
|
||
if (perpY > 0) { perpX = -perpX; perpY = -perpY; } // always arch toward top of screen
|
||
const archH = Math.min(len * 0.35, 180);
|
||
const cpx = (from.x + to.x) / 2 + perpX * archH;
|
||
const cpy = (from.y + to.y) / 2 + perpY * archH;
|
||
|
||
const g = this.add.graphics().setDepth(DEPTH.badge - 1);
|
||
const LINE_W = 6, STROKE_W = 3, ARROW_LEN = 26, ARROW_WID = 12;
|
||
|
||
const drawArc = (t) => {
|
||
g.clear();
|
||
const steps = Math.max(2, Math.ceil(t * 64));
|
||
|
||
// compute bezier points once, reuse for both passes
|
||
const pts = [];
|
||
for (let i = 0; i <= steps; i++) {
|
||
const tt = (i / steps) * t;
|
||
pts.push(
|
||
(1 - tt) * (1 - tt) * from.x + 2 * (1 - tt) * tt * cpx + tt * tt * to.x,
|
||
(1 - tt) * (1 - tt) * from.y + 2 * (1 - tt) * tt * cpy + tt * tt * to.y,
|
||
);
|
||
}
|
||
|
||
const drawPath = (w, col, alpha) => {
|
||
g.lineStyle(w, col, alpha);
|
||
g.beginPath();
|
||
for (let i = 0; i <= steps; i++) {
|
||
if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]);
|
||
else g.lineTo(pts[i * 2], pts[i * 2 + 1]);
|
||
}
|
||
g.strokePath();
|
||
};
|
||
|
||
// white outline pass, then colored fill pass
|
||
drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
|
||
drawPath(LINE_W, color, 0.92);
|
||
|
||
// arrowhead at the current tip, aligned to the curve tangent
|
||
if (t > 0.05) {
|
||
const prevT = Math.max(0, t - 0.04);
|
||
const tx = (1-t)*(1-t)*from.x + 2*(1-t)*t*cpx + t*t*to.x;
|
||
const ty = (1-t)*(1-t)*from.y + 2*(1-t)*t*cpy + t*t*to.y;
|
||
const px = (1-prevT)*(1-prevT)*from.x + 2*(1-prevT)*prevT*cpx + prevT*prevT*to.x;
|
||
const py = (1-prevT)*(1-prevT)*from.y + 2*(1-prevT)*prevT*cpy + prevT*prevT*to.y;
|
||
const adx = tx - px, ady = ty - py;
|
||
const aLen = Math.sqrt(adx * adx + ady * ady) || 1;
|
||
const ax = adx / aLen, ay = ady / aLen;
|
||
const s = STROKE_W;
|
||
// white outline triangle slightly larger
|
||
g.fillStyle(0xffffff, 0.85);
|
||
g.fillTriangle(
|
||
tx + ax * s, ty + ay * s,
|
||
tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s),
|
||
tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s),
|
||
);
|
||
// colored fill triangle
|
||
g.fillStyle(color, 0.95);
|
||
g.fillTriangle(
|
||
tx, ty,
|
||
tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID,
|
||
tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID,
|
||
);
|
||
}
|
||
};
|
||
|
||
await new Promise((resolve) => {
|
||
const progress = { t: 0 };
|
||
this.tweens.add({
|
||
targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut',
|
||
onUpdate: () => drawArc(progress.t),
|
||
onComplete: () => { drawArc(1); resolve(); },
|
||
});
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
async animateBattle(b) {
|
||
if (!b) return;
|
||
const arrowGfx = await this.animateAttackArrow(b);
|
||
await this.animateBattleModal(b);
|
||
arrowGfx?.destroy();
|
||
}
|
||
|
||
async animateBattleModal(b) {
|
||
const attackerSeat = b.attackerSeat ?? this.gs.owner[b.from];
|
||
const defenderSeat = b.defenderSeat ?? this.gs.owner[b.to];
|
||
const attackerColor = this.colorOf(attackerSeat);
|
||
const defenderColor = this.colorOf(defenderSeat);
|
||
const attackerColorHex = this.colorHexOf(attackerSeat);
|
||
const defenderColorHex = this.colorHexOf(defenderSeat);
|
||
const attackerName = this.gs.players[attackerSeat].name;
|
||
const defenderName = this.gs.players[defenderSeat].name;
|
||
|
||
const W = 560, H = 320;
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
const startPos = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y);
|
||
|
||
// ── Container (all modal children use local coordinates) ──────────────────
|
||
const container = this.add.container(startPos.x, startPos.y).setDepth(DEPTH.popup).setScale(0.05);
|
||
|
||
// Background panel
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0xf5f0e8, 0.97);
|
||
bg.fillRoundedRect(-W / 2, -H / 2, W, H, 14);
|
||
bg.lineStyle(3, COLORS.gold, 1);
|
||
bg.strokeRoundedRect(-W / 2, -H / 2, W, H, 14);
|
||
container.add(bg);
|
||
|
||
// Heading — three separate text objects so each territory name uses its owner's color
|
||
const headY = -H / 2 + 36;
|
||
const headStyle = { fontFamily: 'Righteous', fontSize: '22px' };
|
||
const tA = this.add.text(0, headY, TERRITORIES[b.from].name, { ...headStyle, color: attackerColorHex }).setOrigin(0.5);
|
||
const tSep = this.add.text(0, headY, ' ⚔ ', { ...headStyle, color: COLORS.goldHex }).setOrigin(0.5);
|
||
const tD = this.add.text(0, headY, TERRITORIES[b.to].name, { ...headStyle, color: defenderColorHex }).setOrigin(0.5);
|
||
// Reposition as a centered row now that widths are known
|
||
const totalW = tA.width + tSep.width + tD.width;
|
||
tA.setX( -totalW / 2 + tA.width / 2);
|
||
tSep.setX(-totalW / 2 + tA.width + tSep.width / 2);
|
||
tD.setX( -totalW / 2 + tA.width + tSep.width + tD.width / 2);
|
||
container.add([tA, tSep, tD]);
|
||
|
||
// Divider
|
||
const div = this.add.graphics();
|
||
div.lineStyle(1, COLORS.accent, 0.4);
|
||
div.lineBetween(-W / 2 + 20, -H / 2 + 64, W / 2 - 20, -H / 2 + 64);
|
||
container.add(div);
|
||
|
||
// Player name labels
|
||
container.add(this.add.text(-110, -H / 2 + 82, attackerName, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: attackerColorHex, align: 'center',
|
||
}).setOrigin(0.5));
|
||
container.add(this.add.text(110, -H / 2 + 82, defenderName, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: defenderColorHex, align: 'center',
|
||
}).setOrigin(0.5));
|
||
|
||
// ── Dice layout ──────────────────────────────────────────────────────────
|
||
const DIE = 52, STRIDE = 62;
|
||
const dieYs = (count) => {
|
||
const totalH = count * STRIDE - (STRIDE - DIE);
|
||
const top = -totalH / 2 + DIE / 2 + 30; // center in lower portion of modal
|
||
return Array.from({ length: count }, (_, i) => top + i * STRIDE);
|
||
};
|
||
const aYs = dieYs(b.aRolls.length);
|
||
const dYs = dieYs(b.dRolls.length);
|
||
|
||
const makeDie = (localX, localY, color) => {
|
||
const g = this.add.graphics();
|
||
g.fillStyle(color, 1);
|
||
g.fillRoundedRect(localX - DIE / 2, localY - DIE / 2, DIE, DIE, 8);
|
||
g.lineStyle(3, 0xffffff, 0.6);
|
||
g.strokeRoundedRect(localX - DIE / 2, localY - DIE / 2, DIE, DIE, 8);
|
||
const t = this.add.text(localX, localY, '?', {
|
||
fontFamily: 'Righteous', fontSize: '28px', color: '#ffffff',
|
||
}).setOrigin(0.5);
|
||
container.add([g, t]);
|
||
return { g, t, localX, localY };
|
||
};
|
||
|
||
const aDice = aYs.map((y) => makeDie(-110, y, attackerColor));
|
||
const dDice = dYs.map((y) => makeDie( 110, y, defenderColor));
|
||
|
||
// ── Zoom-in tween ─────────────────────────────────────────────────────────
|
||
await new Promise((resolve) => {
|
||
this.tweens.add({
|
||
targets: container, x: cx, y: cy, scaleX: 1, scaleY: 1,
|
||
duration: 1000, ease: 'Back.easeOut', onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// ── Dice rolling ─────────────────────────────────────────────────────────
|
||
playSound(this, SFX.DICE_ROLL);
|
||
for (let tick = 0; tick < 10; tick++) {
|
||
aDice.forEach((d) => d.t.setText(String(Math.ceil(Math.random() * 6))));
|
||
dDice.forEach((d) => d.t.setText(String(Math.ceil(Math.random() * 6))));
|
||
await this.delay(60);
|
||
}
|
||
aDice.forEach((d, i) => d.t.setText(String(b.aRolls[i])));
|
||
dDice.forEach((d, i) => d.t.setText(String(b.dRolls[i])));
|
||
|
||
// ── Pair-by-pair resolution arrows ────────────────────────────────────────
|
||
const pairs = Math.min(b.aRolls.length, b.dRolls.length);
|
||
const diceArrows = [];
|
||
for (let i = 0; i < pairs; i++) {
|
||
const aWins = b.aRolls[i] > b.dRolls[i];
|
||
const winCol = aWins ? attackerColor : defenderColor;
|
||
const fromPos = { x: cx + (aWins ? -110 : 110), y: cy + (aWins ? aYs[i] : dYs[i]) };
|
||
const toPos = { x: cx + (aWins ? 110 : -110), y: cy + (aWins ? dYs[i] : aYs[i]) };
|
||
const loser = aWins ? dDice[i] : aDice[i];
|
||
|
||
const ag = await this.animateDiceArrow(fromPos, toPos, winCol);
|
||
diceArrows.push(ag);
|
||
loser.g.setAlpha(0.22);
|
||
loser.t.setAlpha(0.22);
|
||
await this.delay(200);
|
||
}
|
||
|
||
if (b.conquered) playSound(this, SFX.SWORD_HIT);
|
||
await this.delay(600);
|
||
|
||
container.destroy(true);
|
||
diceArrows.forEach((g) => g.destroy());
|
||
}
|
||
|
||
async animateDiceArrow(from, to, color) {
|
||
const dx = to.x - from.x, dy = to.y - from.y;
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
let perpX = -dy / len, perpY = dx / len;
|
||
if (perpY > 0) { perpX = -perpX; perpY = -perpY; }
|
||
const archH = Math.min(len * 0.5, 80);
|
||
const cpx = (from.x + to.x) / 2 + perpX * archH;
|
||
const cpy = (from.y + to.y) / 2 + perpY * archH;
|
||
|
||
const g = this.add.graphics().setDepth(DEPTH.popup + 2);
|
||
const LINE_W = 4, STROKE_W = 2, ARROW_LEN = 18, ARROW_WID = 9;
|
||
|
||
const drawArc = (t) => {
|
||
g.clear();
|
||
const steps = Math.max(2, Math.ceil(t * 48));
|
||
const pts = [];
|
||
for (let i = 0; i <= steps; i++) {
|
||
const tt = (i / steps) * t;
|
||
pts.push(
|
||
(1 - tt) * (1 - tt) * from.x + 2 * (1 - tt) * tt * cpx + tt * tt * to.x,
|
||
(1 - tt) * (1 - tt) * from.y + 2 * (1 - tt) * tt * cpy + tt * tt * to.y,
|
||
);
|
||
}
|
||
const drawPath = (w, col, alpha) => {
|
||
g.lineStyle(w, col, alpha);
|
||
g.beginPath();
|
||
for (let i = 0; i <= steps; i++) {
|
||
if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]);
|
||
else g.lineTo(pts[i * 2], pts[i * 2 + 1]);
|
||
}
|
||
g.strokePath();
|
||
};
|
||
drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
|
||
drawPath(LINE_W, color, 0.92);
|
||
|
||
if (t > 0.05) {
|
||
const prevT = Math.max(0, t - 0.04);
|
||
const tx = (1-t)*(1-t)*from.x + 2*(1-t)*t*cpx + t*t*to.x;
|
||
const ty = (1-t)*(1-t)*from.y + 2*(1-t)*t*cpy + t*t*to.y;
|
||
const px = (1-prevT)*(1-prevT)*from.x + 2*(1-prevT)*prevT*cpx + prevT*prevT*to.x;
|
||
const py = (1-prevT)*(1-prevT)*from.y + 2*(1-prevT)*prevT*cpy + prevT*prevT*to.y;
|
||
const adx = tx - px, ady = ty - py;
|
||
const aLen = Math.sqrt(adx * adx + ady * ady) || 1;
|
||
const ax = adx / aLen, ay = ady / aLen;
|
||
const s = STROKE_W;
|
||
g.fillStyle(0xffffff, 0.85);
|
||
g.fillTriangle(
|
||
tx + ax * s, ty + ay * s,
|
||
tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s),
|
||
tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s),
|
||
);
|
||
g.fillStyle(color, 0.95);
|
||
g.fillTriangle(
|
||
tx, ty,
|
||
tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID,
|
||
tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID,
|
||
);
|
||
}
|
||
};
|
||
|
||
await new Promise((resolve) => {
|
||
const progress = { t: 0 };
|
||
this.tweens.add({
|
||
targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut',
|
||
onUpdate: () => drawArc(progress.t),
|
||
onComplete: () => { drawArc(1); resolve(); },
|
||
});
|
||
});
|
||
|
||
return g;
|
||
}
|
||
|
||
async animateConquest(fromId, toId, n, attackerSeat) {
|
||
const color = this.colorOf(attackerSeat);
|
||
const from = this.boardToScreen(TERRITORIES[fromId].x, TERRITORIES[fromId].y);
|
||
const to = this.boardToScreen(TERRITORIES[toId].x, TERRITORIES[toId].y);
|
||
|
||
const dx = to.x - from.x, dy = to.y - from.y;
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const ax = dx / len, ay = dy / len;
|
||
|
||
const LINE_W = 6, STROKE_W = 3, ARROW_LEN = 26, ARROW_WID = 12;
|
||
const g = this.add.graphics().setDepth(DEPTH.badge - 1);
|
||
|
||
const drawLine = (t) => {
|
||
g.clear();
|
||
const tipX = from.x + dx * t, tipY = from.y + dy * t;
|
||
g.lineStyle(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
|
||
g.beginPath(); g.moveTo(from.x, from.y); g.lineTo(tipX, tipY); g.strokePath();
|
||
g.lineStyle(LINE_W, color, 0.92);
|
||
g.beginPath(); g.moveTo(from.x, from.y); g.lineTo(tipX, tipY); g.strokePath();
|
||
if (t > 0.05) {
|
||
const s = STROKE_W;
|
||
g.fillStyle(0xffffff, 0.85);
|
||
g.fillTriangle(
|
||
tipX + ax * s, tipY + ay * s,
|
||
tipX - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), tipY - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s),
|
||
tipX - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), tipY - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s),
|
||
);
|
||
g.fillStyle(color, 0.95);
|
||
g.fillTriangle(
|
||
tipX, tipY,
|
||
tipX - ax * ARROW_LEN + ay * ARROW_WID, tipY - ay * ARROW_LEN - ax * ARROW_WID,
|
||
tipX - ax * ARROW_LEN - ay * ARROW_WID, tipY - ay * ARROW_LEN + ax * ARROW_WID,
|
||
);
|
||
}
|
||
};
|
||
|
||
// Phase 1: draw straight line with arrow over 1 s
|
||
await new Promise((resolve) => {
|
||
const progress = { t: 0 };
|
||
this.tweens.add({
|
||
targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut',
|
||
onUpdate: () => drawLine(progress.t),
|
||
onComplete: () => { drawLine(1); resolve(); },
|
||
});
|
||
});
|
||
|
||
// Phase 2: army count floats rise over 1 s while arrow stays visible
|
||
const lossText = this.add.text(from.x, from.y - BADGE_R - 8, `−${n}`, {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.dangerHex,
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0.5).setDepth(DEPTH.label + 1);
|
||
const gainText = this.add.text(to.x, to.y - BADGE_R - 8, `+${n}`, {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: '#4fd06a',
|
||
stroke: '#000000', strokeThickness: 3,
|
||
}).setOrigin(0.5).setDepth(DEPTH.label + 1);
|
||
|
||
await new Promise((resolve) => {
|
||
this.tweens.add({
|
||
targets: [lossText, gainText], y: '-=55', duration: 1000,
|
||
ease: 'Sine.easeOut', onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
// Phase 3: fade everything out
|
||
await new Promise((resolve) => {
|
||
this.tweens.add({ targets: g, alpha: 0, duration: 400, ease: 'Linear' });
|
||
this.tweens.add({
|
||
targets: [lossText, gainText], alpha: 0, duration: 400,
|
||
ease: 'Linear', onComplete: resolve,
|
||
});
|
||
});
|
||
|
||
g.destroy();
|
||
lossText.destroy();
|
||
gainText.destroy();
|
||
}
|
||
|
||
// ── turn loop ───────────────────────────────────────────────────────────────────
|
||
async advance() {
|
||
if (this.busy) return;
|
||
if (isGameOver(this.gs)) { this.showGameOver(); return; }
|
||
if (this.gs.current === this.humanSeat) { this.render(); return; } // wait for input
|
||
this.busy = true;
|
||
try {
|
||
await this.runAiTurn(this.gs.current);
|
||
} finally {
|
||
this.busy = false;
|
||
}
|
||
this.render();
|
||
if (isGameOver(this.gs)) { this.showGameOver(); return; }
|
||
this.time.delayedCall(60, () => this.advance());
|
||
}
|
||
|
||
async runAiTurn(seat) {
|
||
const skill = this.gs.players[seat].skill;
|
||
this.setPortraitThinking(seat, true);
|
||
await this.delay(nextThinkDelay(skill));
|
||
|
||
// reinforce: trades then placements
|
||
let g = 0;
|
||
while (this.gs.phase === 'reinforce' && g++ < 12) {
|
||
const set = chooseTrade(this.gs, seat, skill);
|
||
if (!set) break;
|
||
this.gs = tradeCards(this.gs, set);
|
||
this.render(); await this.delay(420);
|
||
}
|
||
for (const step of planReinforcements(this.gs, seat, skill)) {
|
||
if (this.gs.phase !== 'reinforce') break;
|
||
this.gs = placeArmies(this.gs, step.terr, step.n);
|
||
this.render(); await this.delay(260);
|
||
}
|
||
g = 0;
|
||
while (this.gs.phase === 'reinforce' && g++ < 200) {
|
||
const mine = territoriesOf(this.gs, seat);
|
||
this.gs = placeArmies(this.gs, mine[0], this.gs.reinforcements);
|
||
}
|
||
|
||
// attack
|
||
g = 0;
|
||
while (this.gs.phase === 'attack' && g++ < 400) {
|
||
const atk = chooseAttack(this.gs, seat, skill);
|
||
if (!atk) { this.gs = endAttack(this.gs); break; }
|
||
this.gs = resolveAttack(this.gs, atk.from, atk.to, atk.numDice);
|
||
await this.animateBattle(this.gs.lastBattle);
|
||
if (this.gs.pendingConquest) {
|
||
const n = chooseAdvance(this.gs, seat, skill);
|
||
const { from: cFrom, to: cTo } = this.gs.lastBattle;
|
||
this.gs = advanceArmies(this.gs, n);
|
||
this.render();
|
||
await this.animateConquest(cFrom, cTo, n, seat);
|
||
} else {
|
||
this.render();
|
||
}
|
||
if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; }
|
||
await this.delay(200);
|
||
}
|
||
|
||
// fortify
|
||
if (this.gs.phase === 'fortify') {
|
||
const f = chooseFortify(this.gs, seat, skill);
|
||
this.gs = f ? fortify(this.gs, f.from, f.to, f.n) : endTurn(this.gs);
|
||
this.render(); await this.delay(220);
|
||
}
|
||
this.setPortraitThinking(seat, false);
|
||
}
|
||
|
||
setPortraitThinking(seat, on) {
|
||
const p = this.portraits.find((x) => x.seat === seat);
|
||
p?.portrait?.playEmotion?.(on ? 'happy' : 'idle');
|
||
}
|
||
|
||
// ── game over ───────────────────────────────────────────────────────────────────
|
||
showGameOver() {
|
||
if (this.gameOverShown) return;
|
||
this.gameOverShown = true;
|
||
playSound(this, SFX.VICTORY_SHORT);
|
||
this.postHistory().catch(() => {});
|
||
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setInteractive().setDepth(DEPTH.banner);
|
||
const W = 640, H = 320;
|
||
const g = this.add.graphics().setDepth(DEPTH.banner + 1);
|
||
g.fillStyle(COLORS.panel, 1); g.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 16);
|
||
g.lineStyle(3, COLORS.gold, 1); g.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 16);
|
||
const winner = this.gs.winner;
|
||
const won = winner === this.humanSeat;
|
||
this.add.text(cx, cy - 80, won ? 'Victory!' : 'Defeat', {
|
||
fontFamily: 'Righteous', fontSize: '56px', color: won ? COLORS.goldHex : COLORS.dangerHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.banner + 2);
|
||
this.add.text(cx, cy - 8, winner != null ? `${this.gs.players[winner].name} conquers the world` : 'Stalemate', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(DEPTH.banner + 2);
|
||
new Button(this, cx, cy + H / 2 - 46, 'Back to Menu', () => this.scene.start('GameMenu'),
|
||
{ width: 300, fontSize: 24 }).setDepth(DEPTH.banner + 2);
|
||
}
|
||
|
||
async postHistory() {
|
||
const s = this.gs;
|
||
const counts = s.players.map((p, seat) => countTerritories(s, seat));
|
||
const result = s.winner === this.humanSeat ? 'win' : 'loss';
|
||
await api.post('/history/single-player', {
|
||
slug: 'risk',
|
||
score: counts[this.humanSeat],
|
||
opponentScores: counts.filter((_, i) => i !== this.humanSeat),
|
||
result,
|
||
});
|
||
}
|
||
}
|