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, }); } }