fertig-classic-games/public/src/games/risk/RiskGame.js

1003 lines
44 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 { 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,
});
}
}