2531 lines
116 KiB
JavaScript
2531 lines
116 KiB
JavaScript
// SWDBGGame.js — Phaser scene for Star Wars Deckbuilder (presentation only; all
|
|
// rules live in SWDBGLogic). The scene pumps the engine's decision queue:
|
|
// human decisions enable click UI, AI decisions run on a think delay, and each
|
|
// action's event stream drives a sequential animation queue before re-render.
|
|
//
|
|
// Interaction model on your turn:
|
|
// · click a hand card to play it (or Play All)
|
|
// · click a card in play to toggle it into your attack squad, then click the
|
|
// enemy base panel or a highlighted galaxy-row target to strike
|
|
// · USE pills appear under cards whose ability is available
|
|
// · click a highlighted galaxy-row card (no squad selected) to purchase it
|
|
|
|
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { playSound, playForceMove, SFX } from '../../ui/Sounds.js';
|
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
import { api } from '../../services/api.js';
|
|
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
|
|
import { loadCardData, cardDef, baseDef, basesFor, getData, FACTION_INFO } from './SWDBGData.js';
|
|
import {
|
|
newGame, pendingDecision, takeEvents, publicView, legalActions,
|
|
actTurn, actChooseBase, actChooseTarget, actOppDiscard, actOppChoice, actChooseOption,
|
|
entryAttack, forceWith, purchaseCost, FORCE_MAX,
|
|
} from './SWDBGLogic.js';
|
|
import { decide, nextThinkDelay } from './SWDBGAI.js';
|
|
|
|
const C = {
|
|
bg: 0x05070f, bgTop: 0x0a0f1f,
|
|
panel: 0x0d1424, panelEdge: 0x22304f,
|
|
text: '#e8ecf5', muted: '#8a94ab', gold: 0xc9a54a, goldHex: '#c9a54a',
|
|
good: 0x3fbf6f, goodHex: '#3fbf6f', bad: 0xd6604d, badHex: '#e06c5c',
|
|
empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a,
|
|
artWindow: 0x111a30, plate: 0x1a2440,
|
|
// Card face background — same dark navy as the rules-text box, so the loose
|
|
// bits of text drawn directly on the bare face (outside the plate/art-window
|
|
// boxes) use the same light tones already proven readable on that box.
|
|
cardBg: 0x151d33,
|
|
statAtkHex: '#e06c5c', statResHex: '#e8c860', statForceHex: '#9fd8f0',
|
|
bountyHex: '#b06bd8', baseTextHex: '#c7cede',
|
|
};
|
|
const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hover: 100 };
|
|
|
|
// Deep-dive card inspector: gameplay-relevant "zones" on a unit/capital card
|
|
// to annotate with a box + leader line + tooltip once a hovered card has been
|
|
// centered on screen (see openDeepDive/revealZones/showZoneCallout). All rect
|
|
// coordinates are in the card's own local space (origin at its center),
|
|
// evaluated against the fixed 340x470 preview size makeCard builds deep-dive
|
|
// cards at (see its _hoverBuild) — the card is only ever translated (never
|
|
// rescaled) once centered, so these stay valid throughout. Reveal order is
|
|
// array order. `anchorX` only supplies the horizontal offscreen offset for
|
|
// the tooltip pill (based on which side it sits on) — the vertical position
|
|
// is computed dynamically at reveal time (see revealZones) by stacking each
|
|
// side's pills top-to-bottom based on their actual measured height, so pills
|
|
// never overlap regardless of how many zones apply to a given card.
|
|
const CARD_DEEPDIVE_ZONES = [
|
|
{
|
|
id: 'cost', side: 'left', title: 'Purchase Cost',
|
|
text: 'Spend this many ▣ Resources to buy this card from the galaxy row.',
|
|
rect: () => ({ x: -138.7 - 32.3, y: -203.7 - 32.3, w: 64.6, h: 64.6 }),
|
|
anchorX: (w) => -w / 2 - 130,
|
|
},
|
|
{
|
|
id: 'unique', side: 'right', title: 'Unique',
|
|
text: 'A gold inner border means only one copy of this card exists in the deck.',
|
|
condition: (def) => !!def.unique,
|
|
rect: (w, h) => ({ x: w / 2 - 32, y: -h / 2 + 4, w: 28, h: 28 }),
|
|
anchorX: (w) => w / 2 + 130,
|
|
},
|
|
{
|
|
id: 'attack', side: 'left', title: 'Attack ⚔',
|
|
text: "Adds to your squad's total attack when committed to an attack. Gold text means it's currently boosted.",
|
|
condition: (def) => !!def.attack,
|
|
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'attack') - 16, w: 110, h: 32 }),
|
|
anchorX: (w) => -w / 2 - 130,
|
|
},
|
|
{
|
|
id: 'resources', side: 'left', title: 'Resources ▣',
|
|
text: 'Adds to your resource pool this turn — spend resources to buy cards from the galaxy row.',
|
|
condition: (def) => !!def.resources,
|
|
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'resources') - 16, w: 110, h: 32 }),
|
|
anchorX: (w) => -w / 2 - 130,
|
|
},
|
|
{
|
|
id: 'force', side: 'left', title: 'Force ◈',
|
|
text: 'Shifts the Force marker toward your side. Maxing it out on your side grants a bonus resource each turn.',
|
|
condition: (def) => !!def.force,
|
|
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'force') - 16, w: 110, h: 32 }),
|
|
anchorX: (w) => -w / 2 - 130,
|
|
},
|
|
{
|
|
id: 'hp', side: 'right', title: 'Hit Points',
|
|
text: 'Capitals stay in play and soak damage instead of dying immediately — destroyed when this reaches 0.',
|
|
condition: (def) => def.type === 'capital',
|
|
rect: () => ({ x: 139.9 - 30.15, y: 204.9 - 30.15, w: 60.3, h: 60.3 }),
|
|
anchorX: (w) => w / 2 + 130,
|
|
},
|
|
{
|
|
id: 'bounty', side: 'right', title: 'Bounty',
|
|
text: 'Commit enough squad attack (matching this number) to defeat this row card instead of buying it, and claim the reward shown.',
|
|
condition: (def) => def.target != null,
|
|
rect: (w) => ({ x: -w / 2 + 10, y: 208, w: w - 20, h: 30 }),
|
|
anchorX: (w) => w / 2 + 130,
|
|
},
|
|
];
|
|
const DEEPDIVE_ZONES = { card: CARD_DEEPDIVE_ZONES };
|
|
|
|
// Mirrors makeCard's own stat() helper: slots are only consumed by truthy
|
|
// stats, in the fixed order attack -> resources -> force.
|
|
function statSlotY(def, key) {
|
|
let sy = 2.2;
|
|
for (const [k, v] of [['attack', def.attack], ['resources', def.resources], ['force', def.force]]) {
|
|
if (!v) continue;
|
|
if (k === key) return sy;
|
|
sy += 39.95;
|
|
}
|
|
return 2.2;
|
|
}
|
|
|
|
export default class SWDBGGame extends Phaser.Scene {
|
|
constructor() { super('SWDBGGame'); }
|
|
|
|
init(data) {
|
|
this._initData = data;
|
|
this.gameDef = data?.game ?? { slug: 'swdbg', name: 'Star Wars' };
|
|
this.opponents = data?.opponents ?? [];
|
|
this.playfield = data?.playfield ?? null;
|
|
this.cardBack = data?.cardBack ?? null;
|
|
this.aiSkill = this.opponents[0]?.skill ?? 3;
|
|
this.humanSeat = 0;
|
|
this.gs = null;
|
|
this.busy = false;
|
|
this.mode = { type: 'idle' };
|
|
this.squad = new Set(); // uids committed to the next attack
|
|
this._recorded = false;
|
|
this._rowPos = new Map(); // uid → {x,y}
|
|
this._playPos = new Map(); // uid → {x,y}
|
|
this._pendingCardUids = new Set(); // uids whose deal/refill animation hasn't landed yet
|
|
this._pendingDiscardUids = new Set(); // uids counted in a discard pile but not yet visually landed there
|
|
this._pendingDeckUids = new Set(); // same, for the rare "buy, topdeck it" ability
|
|
this._pendingAttackFrom = []; // {x,y}[] snapshot of the attacking squad's board positions, captured just before dispatch
|
|
this._rowSlots = new Map(); // uid → stable screen-slot index — a card keeps its slot for as long as it's in the row, so buying/losing one never shifts its neighbors
|
|
this._rowGhosts = new Map(); // uid → id — a row card (bought or bountied) still shown in its vacated slot until its own animation actually reaches/removes it
|
|
this._endTurnFromPos = new Map(); // uid → {x,y}, snapshot of an end-of-turn discard's in-play position, taken before it's cleared
|
|
this._endTurnHandLayout = new Map(); // uid → {idx,total}, virtual old-hand-slot layout for a human's end-of-turn hand discard
|
|
this._endTurnGhosts = new Map(); // uid → {seat,id,zone} — an end-of-turn discard stays visible in its old spot until its own fly-away animation begins
|
|
this._dealingInitial = false; // true only during the game-start deal sequence
|
|
this._actionButtons = [];
|
|
this.hoverTimer = null;
|
|
this.hoverVisible = false;
|
|
this.deepDiveTimer = null;
|
|
this._deepDive = null;
|
|
}
|
|
|
|
create() {
|
|
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ }
|
|
this.art = this.cache.json.get('swdbg-artwork') || {};
|
|
loadCardData(this.cache.json.get('swdbg-cards'));
|
|
|
|
this.drawBackground();
|
|
this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board);
|
|
this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row);
|
|
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
|
|
this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx);
|
|
this.buildHoverPopup();
|
|
this.input.on('pointermove', (p) => {
|
|
this.lastPointer = { x: p.x, y: p.y };
|
|
if (this.hoverVisible) this.positionHover(p.x, p.y);
|
|
});
|
|
this.buildStaticUi();
|
|
this.showFactionSelect();
|
|
}
|
|
|
|
drawBackground() {
|
|
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(0);
|
|
return;
|
|
}
|
|
const bg = this.add.graphics().setDepth(0);
|
|
bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1);
|
|
bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
const rng = new Phaser.Math.RandomDataGenerator(['swdbg']);
|
|
for (let i = 0; i < 240; i++) {
|
|
const a = rng.frac() * 0.5 + 0.08;
|
|
bg.fillStyle(0xffffff, a);
|
|
bg.fillCircle(rng.between(0, GAME_WIDTH), rng.between(0, GAME_HEIGHT), rng.frac() * 1.6 + 0.4);
|
|
}
|
|
}
|
|
|
|
buildStaticUi() {
|
|
new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
|
|
{ width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui);
|
|
this.promptText = this.add.text(GAME_WIDTH / 2, 845, '', {
|
|
fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex,
|
|
backgroundColor: '#05070fdd', padding: { x: 16, y: 6 },
|
|
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
|
if (this.opponents[0]) createOpponentPortrait(this, this.opponents[0], 140, 128, 44, DEPTH.ui);
|
|
createPlayerPortrait(this, 140, 812, 44, DEPTH.ui, 'SWDBGGame');
|
|
}
|
|
|
|
// ── faction select ─────────────────────────────────────────────────────────
|
|
showFactionSelect() {
|
|
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
|
|
root.add(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72).setInteractive());
|
|
root.add(this.add.text(GAME_WIDTH / 2, 250, 'CHOOSE YOUR SIDE', {
|
|
fontFamily: 'Righteous', fontSize: '52px', color: C.text,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(GAME_WIDTH / 2, 316, 'The Empire always strikes first.', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
const mk = (x, faction, blurb) => {
|
|
const info = FACTION_INFO[faction];
|
|
const cont = this.add.container(x, 620);
|
|
const g = this.add.graphics();
|
|
g.fillStyle(C.panel, 0.96); g.fillRoundedRect(-240, -190, 480, 380, 16);
|
|
g.lineStyle(4, info.color, 1); g.strokeRoundedRect(-240, -190, 480, 380, 16);
|
|
cont.add(g);
|
|
cont.add(this.add.text(0, -120, info.symbol, { fontSize: '84px', color: info.colorHex }).setOrigin(0.5));
|
|
cont.add(this.add.text(0, -20, info.label.toUpperCase(), {
|
|
fontFamily: 'Righteous', fontSize: '34px', color: info.colorHex,
|
|
}).setOrigin(0.5));
|
|
cont.add(this.add.text(0, 84, blurb, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text, align: 'center',
|
|
wordWrap: { width: 420 }, lineSpacing: 6,
|
|
}).setOrigin(0.5));
|
|
cont.setSize(480, 380);
|
|
cont.setInteractive({ useHandCursor: true });
|
|
cont.on('pointerover', () => this.tweens.add({ targets: cont, scale: 1.04, duration: 120 }));
|
|
cont.on('pointerout', () => this.tweens.add({ targets: cont, scale: 1, duration: 120 }));
|
|
cont.on('pointerdown', () => { root.destroy(); this.startGame(faction); });
|
|
root.add(cont);
|
|
};
|
|
mk(GAME_WIDTH / 2 - 300, 'empire', 'Overwhelming firepower.\nTIE swarms, Star Destroyers,\nand the will of the Emperor.\nYou move first.');
|
|
mk(GAME_WIDTH / 2 + 300, 'rebel', 'Hope, grit, and X-Wings.\nThe Force begins on your side\nof the Balance track.');
|
|
}
|
|
|
|
startGame(faction) {
|
|
this.gs = newGame({ humanFaction: faction });
|
|
const events = takeEvents(this.gs);
|
|
const oppSeat = 1 - this.humanSeat;
|
|
const oppDraws = events.filter((e) => e.type === 'draw' && e.seat === oppSeat);
|
|
const humanDraws = events.filter((e) => e.type === 'draw' && e.seat === this.humanSeat);
|
|
const rowRefills = events.filter((e) => e.type === 'rowRefill');
|
|
const ordered = [...oppDraws, ...humanDraws, ...rowRefills];
|
|
this._dealingInitial = true;
|
|
this.playEvents(ordered, {
|
|
onDone: () => {
|
|
this._dealingInitial = false;
|
|
this.showBanner(faction === 'empire' ? 'You command the Empire — your turn first' : 'The Empire moves first — hold the line');
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── art lookup ─────────────────────────────────────────────────────────────
|
|
artFor(id, base = false) {
|
|
const sheet = base ? this.art.baseSheet : this.art.cardSheet;
|
|
const frame = (base ? this.art.bases : this.art.cards)?.[id];
|
|
// The frame must actually exist in the loaded texture — a mapping that
|
|
// points past the sheet's last row (art not painted yet) falls back to
|
|
// procedural rendering instead of Phaser's silent frame-0 substitute.
|
|
if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)
|
|
&& this.textures.get(sheet.key).has(frame)) {
|
|
return { key: sheet.key, frame };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
factionColor(faction) { return FACTION_INFO[faction]?.color ?? C.neutral; }
|
|
|
|
// ── card renderers ─────────────────────────────────────────────────────────
|
|
makeCard(x, y, inst, w, h, opts = {}) {
|
|
const def = cardDef(inst);
|
|
const cont = this.add.container(x, y);
|
|
const edge = opts.selected ? C.gold : this.factionColor(def.faction);
|
|
const g = this.add.graphics();
|
|
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
if (def.unique) { g.lineStyle(1.5, C.gold, 0.85); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5); }
|
|
// name plate
|
|
const plateH = Math.max(15, h * 0.12);
|
|
g.fillStyle(C.plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3);
|
|
// art window
|
|
const artH = h * (opts.showText ? 0.34 : 0.5);
|
|
const artTop = -h / 2 + plateH + 8;
|
|
g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 7, artTop, w - 14, artH);
|
|
cont.add(g);
|
|
const art = this.artFor(inst.id);
|
|
if (art) {
|
|
const img = this.add.image(0, artTop + artH / 2, art.key, art.frame);
|
|
img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
|
|
cont.add(img);
|
|
} else {
|
|
const glyph = def.type === 'capital' ? '🚀' : (def.traits || []).includes('Fighter') ? '🛩'
|
|
: (def.traits || []).includes('Trooper') ? '🪖' : FACTION_INFO[def.faction].symbol;
|
|
cont.add(this.add.text(0, artTop + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px`, color: '#3d4d75' }).setOrigin(0.5).setAlpha(0.9));
|
|
}
|
|
cont.add(this.add.text(2, -h / 2 + 5 + plateH / 2, def.name, {
|
|
fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 11.5))}px`, color: C.text,
|
|
}).setOrigin(0.5).setScale(Math.min(1, (w - 34) / Math.max(1, def.name.length * (w / 17)))));
|
|
// cost badge
|
|
const cr = Math.max(9, w / 12);
|
|
const cb = this.add.graphics();
|
|
cb.fillStyle(0x0a0f1f, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr + 2);
|
|
cb.fillStyle(C.gold, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr);
|
|
cont.add(cb);
|
|
cont.add(this.add.text(-w / 2 + cr + 3, -h / 2 + cr + 3, `${def.cost}`, {
|
|
fontFamily: 'Righteous', fontSize: `${Math.round(cr * 1.15)}px`, color: '#0a0f1f',
|
|
}).setOrigin(0.5));
|
|
// stat column under the art
|
|
let sy = artTop + artH + 13;
|
|
const stat = (glyph, val, color) => {
|
|
if (!val) return;
|
|
cont.add(this.add.text(-w / 2 + 12, sy, `${glyph}${val}`, {
|
|
fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 11))}px`, color,
|
|
}).setOrigin(0, 0.5));
|
|
sy += Math.max(13, h * 0.085);
|
|
};
|
|
const atk = opts.attackNow != null ? opts.attackNow : def.attack;
|
|
stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.goldHex : C.statAtkHex);
|
|
stat('▣', def.resources, C.statResHex);
|
|
stat('◈', def.force, C.statForceHex);
|
|
// rules text
|
|
if (opts.showText && def.text) {
|
|
const boxTop = artTop + artH + Math.max(30, h * 0.2);
|
|
const boxH = h / 2 - boxTop - (def.target != null ? 24 : 8);
|
|
if (boxH > 18) {
|
|
const tg = this.add.graphics();
|
|
tg.fillStyle(0x151d33, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, boxH, 3);
|
|
cont.add(tg);
|
|
cont.add(this.add.text(0, boxTop + boxH / 2, def.text, {
|
|
fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 16))}px`, color: '#c7cede', align: 'center',
|
|
wordWrap: { width: w - 22 },
|
|
}).setOrigin(0.5));
|
|
}
|
|
}
|
|
// bounty footer
|
|
if (def.target != null) {
|
|
const r = def.reward || {};
|
|
const bits = [];
|
|
if (r.resources) bits.push(`${r.resources}▣`);
|
|
if (r.force) bits.push(`${r.force}◈`);
|
|
if (r.draw) bits.push(`draw ${r.draw}`);
|
|
cont.add(this.add.text(0, h / 2 - 12, `◎ ${def.target} → ${bits.join(' ')}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex,
|
|
}).setOrigin(0.5));
|
|
}
|
|
// hp badge — drawn last so it always sits on top of the rules-text box
|
|
if (def.type === 'capital') {
|
|
const hr = Math.max(9, w / 13);
|
|
const hb = this.add.graphics();
|
|
hb.fillStyle(0x0a0f1f, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr + 2);
|
|
hb.fillStyle(C.bad, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr);
|
|
cont.add(hb);
|
|
const hpLeft = opts.damage != null ? def.hp - opts.damage : def.hp;
|
|
cont.add(this.add.text(w / 2 - hr - 4, h / 2 - hr - 4, `${hpLeft}`, {
|
|
fontFamily: 'Righteous', fontSize: `${Math.round(hr * 1.1)}px`, color: '#fff',
|
|
}).setOrigin(0.5));
|
|
}
|
|
if (!opts.isHoverPreview) {
|
|
opts._hoverBuild = (parent) => {
|
|
this.makeCard(0, 0, inst, 340, 470, { showText: true, isHoverPreview: true, parent });
|
|
return { w: 340, h: 470 };
|
|
};
|
|
opts._deepDiveInfo = { kind: 'card', inst };
|
|
}
|
|
this.finishCard(cont, w, h, opts);
|
|
return cont;
|
|
}
|
|
|
|
makeCardBack(x, y, w, h, parent, label = '') {
|
|
const cont = this.add.container(x, y);
|
|
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists(this.cardBack.key || 'cardbacks')) {
|
|
cont.add(this.add.image(0, 0, this.cardBack.key || 'cardbacks', this.cardBack.spriteIndex).setDisplaySize(w, h).setOrigin(0.5));
|
|
} else {
|
|
const fb = this.cardBack?.fallbackColor ? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x101a33;
|
|
const g = this.add.graphics();
|
|
g.fillStyle(fb, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
g.lineStyle(2, 0x2a3c66, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
g.lineStyle(1, 0x2a3c66, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 5);
|
|
cont.add(g);
|
|
cont.add(this.add.text(0, -6, '✦', { fontSize: `${Math.round(h * 0.28)}px`, color: '#33477a' }).setOrigin(0.5));
|
|
}
|
|
if (label) {
|
|
cont.add(this.add.text(0, h / 2 - 16, label, {
|
|
fontFamily: 'Righteous', fontSize: '15px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
}
|
|
(parent || this.boardLayer).add(cont);
|
|
return cont;
|
|
}
|
|
|
|
makeBaseCard(x, y, faction, base, w, h, opts = {}) {
|
|
const fb = opts.isHoverPreview ? 1.4 : 1; // bump fixed-size text in the hover-zoom popup
|
|
const def = baseDef(faction, base.id);
|
|
const cont = this.add.container(x, y);
|
|
const info = FACTION_INFO[faction];
|
|
const g = this.add.graphics();
|
|
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 9);
|
|
g.lineStyle(3, opts.highlight ? C.gold : info.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 9);
|
|
const artW = w * 0.42;
|
|
g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, artW, h - 12);
|
|
cont.add(g);
|
|
const art = this.artFor(def.id, true);
|
|
if (art) {
|
|
const img = this.add.image(-w / 2 + 6 + artW / 2, 0, art.key, art.frame);
|
|
img.setScale(Math.min(artW / Math.max(img.width, 1), (h - 12) / Math.max(img.height, 1)));
|
|
cont.add(img);
|
|
} else {
|
|
cont.add(this.add.text(-w / 2 + 6 + artW / 2, 0, '🪐', { fontSize: `${Math.round(h * 0.4)}px` }).setOrigin(0.5).setAlpha(0.85));
|
|
}
|
|
const tx = -w / 2 + artW + 14;
|
|
cont.add(this.add.text(tx, -h / 2 + 17, def.name.toUpperCase(), {
|
|
fontFamily: 'Righteous', fontSize: `${Math.max(12, Math.round(w / 15))}px`, color: info.colorHex,
|
|
}).setOrigin(0, 0.5));
|
|
if (opts.showText) {
|
|
cont.add(this.add.text(tx, -h / 2 + 36, def.text, {
|
|
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(12 * fb)}px`, color: C.baseTextHex,
|
|
wordWrap: { width: w - artW - 26 }, lineSpacing: 3,
|
|
}).setOrigin(0, 0));
|
|
}
|
|
// hp bar
|
|
const hpLeft = Math.max(0, def.hp - base.damage);
|
|
const barW = w - artW - 28;
|
|
const bg2 = this.add.graphics();
|
|
bg2.fillStyle(0x0a0f1f, 1); bg2.fillRoundedRect(tx, h / 2 - 26, barW, 16, 8);
|
|
bg2.fillStyle(hpLeft / def.hp > 0.4 ? C.good : C.bad, 1);
|
|
bg2.fillRoundedRect(tx, h / 2 - 26, barW * (hpLeft / def.hp), 16, 8);
|
|
cont.add(bg2);
|
|
cont.add(this.add.text(tx + barW / 2, h / 2 - 18, `${hpLeft} / ${def.hp}`, {
|
|
fontFamily: 'Righteous', fontSize: '13px', color: '#fff',
|
|
}).setOrigin(0.5));
|
|
if (!opts.isHoverPreview) {
|
|
opts._hoverBuild = (parent) => {
|
|
this.makeBaseCard(0, 0, faction, base, 560, 340, { showText: true, isHoverPreview: true, parent });
|
|
return { w: 560, h: 340 };
|
|
};
|
|
}
|
|
this.finishCard(cont, w, h, opts);
|
|
return cont;
|
|
}
|
|
|
|
finishCard(cont, w, h, opts) {
|
|
if (opts.committed) {
|
|
const ov = this.add.graphics();
|
|
ov.fillStyle(0x000000, 0.45); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
cont.add(ov);
|
|
cont.add(this.add.text(0, 0, '✓', { fontSize: `${Math.round(h * 0.3)}px`, color: '#8a94ab' }).setOrigin(0.5));
|
|
}
|
|
if (opts.highlightBuy) {
|
|
const hl = this.add.graphics();
|
|
hl.lineStyle(3, C.good, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9);
|
|
cont.add(hl);
|
|
this.tweens.add({ targets: hl, alpha: 0.35, duration: 520, yoyo: true, repeat: -1 });
|
|
}
|
|
if (opts.highlightTarget) {
|
|
const hl = this.add.graphics();
|
|
hl.lineStyle(3.5, C.bad, 1); hl.strokeRoundedRect(-w / 2 - 5, -h / 2 - 5, w + 10, h + 10, 9);
|
|
hl.lineStyle(2, C.bad, 0.8);
|
|
hl.lineBetween(-w / 2 - 12, 0, -w / 2 + 2, 0); hl.lineBetween(w / 2 - 2, 0, w / 2 + 12, 0);
|
|
cont.add(hl);
|
|
this.tweens.add({ targets: hl, alpha: 0.4, duration: 420, yoyo: true, repeat: -1 });
|
|
}
|
|
if (opts.highlightGold) {
|
|
const hl = this.add.graphics();
|
|
hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9);
|
|
cont.add(hl);
|
|
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
|
|
}
|
|
if (opts.highlightDiscard) {
|
|
const ov = this.add.graphics();
|
|
ov.fillStyle(C.bad, 0.4); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
|
|
cont.add(ov);
|
|
this.tweens.add({ targets: ov, alpha: 0.12, duration: 460, yoyo: true, repeat: -1 });
|
|
}
|
|
const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
|
|
if (opts.onClick || wantHoverPreview) {
|
|
cont.setSize(w, h);
|
|
cont.setInteractive({ useHandCursor: !!opts.onClick });
|
|
if (opts.onClick) {
|
|
if (opts.hover !== false) {
|
|
const baseY = cont.y;
|
|
cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 10, duration: 100 }); });
|
|
cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 }));
|
|
}
|
|
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); });
|
|
}
|
|
if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild, opts._deepDiveInfo);
|
|
}
|
|
(opts.parent || this.boardLayer).add(cont);
|
|
}
|
|
|
|
// ── Hover-to-zoom card preview ───────────────────────────────────────────────
|
|
buildHoverPopup() {
|
|
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
|
|
}
|
|
|
|
attachHover(hitObj, buildFn, deepDiveInfo) {
|
|
hitObj.on('pointerover', () => {
|
|
if (this.hoverTimer) this.hoverTimer.remove();
|
|
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
|
|
this.hoverTimer = this.time.delayedCall(500, () => {
|
|
this.showHover(buildFn);
|
|
if (deepDiveInfo && DEEPDIVE_ZONES[deepDiveInfo.kind]) {
|
|
this.deepDiveTimer = this.time.delayedCall(1000, () => this.openDeepDive(deepDiveInfo));
|
|
}
|
|
});
|
|
});
|
|
hitObj.on('pointerout', () => {
|
|
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
|
|
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
|
|
this.hideHover();
|
|
});
|
|
}
|
|
|
|
showHover(buildFn) {
|
|
this.hoverPopup.removeAll(true);
|
|
const { w, h } = buildFn(this.hoverPopup);
|
|
const shadow = this.add.graphics();
|
|
shadow.fillStyle(0x000000, 0.45);
|
|
shadow.fillRoundedRect(-w / 2 - 8, -h / 2 - 8, w + 16, h + 16, 14);
|
|
this.hoverPopup.addAt(shadow, 0);
|
|
this.hoverPopup.setData('w', w);
|
|
this.hoverPopup.setData('h', h);
|
|
this.hoverVisible = true;
|
|
this.hoverPopup.setVisible(true);
|
|
const p = this.lastPointer ?? { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
|
|
this.positionHover(p.x, p.y);
|
|
}
|
|
|
|
positionHover(px, py) {
|
|
const w = this.hoverPopup.getData('w') ?? 340;
|
|
const h = this.hoverPopup.getData('h') ?? 470;
|
|
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
|
|
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
|
|
this.hoverPopup.setPosition(x, y);
|
|
}
|
|
|
|
hideHover() {
|
|
this.hoverVisible = false;
|
|
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
|
|
}
|
|
|
|
// ── deep-dive card inspector ─────────────────────────────────────────────────
|
|
// Triggered from attachHover once the ordinary hover preview has stayed up
|
|
// for another second (see attachHover/DEEPDIVE_ZONES). Flies the currently
|
|
// previewed card to screen center, then reveals its gameplay-zone callouts
|
|
// one at a time; "Close" reverses everything back to the hover-preview spot.
|
|
// Deliberately doesn't reuse modalRoot()/this._modal/closeModal() — this
|
|
// modal needs a bespoke close animation and must never close on an
|
|
// incidental click, only via the Close button.
|
|
openDeepDive(deepDiveInfo) {
|
|
if (this._modal || this._deepDive || this.busy) return;
|
|
const returnX = this.hoverPopup.x, returnY = this.hoverPopup.y;
|
|
this.hideHover();
|
|
|
|
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
|
|
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6);
|
|
dim.setInteractive();
|
|
dim.on('pointerdown', () => this.closeDeepDive());
|
|
root.add(dim);
|
|
const cardHolder = this.add.container(returnX, returnY);
|
|
root.add(cardHolder);
|
|
|
|
const w = 340, h = 470;
|
|
this.makeCard(0, 0, deepDiveInfo.inst, w, h, { showText: true, isHoverPreview: true, parent: cardHolder });
|
|
// Sits on top of the card (added after it), same size — wins topOnly
|
|
// hit-testing over `dim` so clicks on the card don't close the modal.
|
|
const zone = this.add.zone(0, 0, w, h).setInteractive();
|
|
cardHolder.add(zone);
|
|
|
|
// offer Buy/Attack right alongside Close when this card is legally
|
|
// actionable right now — mutually exclusive (buy requires no committed
|
|
// squad, attack requires one), so at most one ever joins Close
|
|
const buyInfo = deepDiveInfo.kind === 'card' ? this.deepDiveBuyInfo(deepDiveInfo.inst) : null;
|
|
const canAttack = !buyInfo && deepDiveInfo.kind === 'card' && this.deepDiveAttackable(deepDiveInfo.inst);
|
|
const hasExtra = !!buyInfo || canAttack;
|
|
const btnY = GAME_HEIGHT / 2 + h / 2 + 70;
|
|
const closeBtn = new Button(this, hasExtra ? GAME_WIDTH / 2 + 90 : GAME_WIDTH / 2, btnY, 'Close',
|
|
() => this.closeDeepDive(), { width: 160, fontSize: 20 });
|
|
closeBtn.setDepth(DEPTH.overlay + 1);
|
|
root.add(closeBtn);
|
|
if (buyInfo) {
|
|
const buyBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Buy',
|
|
() => this.deepDiveBuy(buyInfo), { width: 160, fontSize: 20 });
|
|
buyBtn.setDepth(DEPTH.overlay + 1);
|
|
root.add(buyBtn);
|
|
} else if (canAttack) {
|
|
const atkBtn = new Button(this, GAME_WIDTH / 2 - 90, btnY, 'Attack',
|
|
() => this.deepDiveAttack(deepDiveInfo.inst), { width: 160, fontSize: 20 });
|
|
atkBtn.setDepth(DEPTH.overlay + 1);
|
|
root.add(atkBtn);
|
|
}
|
|
|
|
this._deepDive = {
|
|
modal: root, cardHolder, zoneTimers: [], zoneNodes: [],
|
|
returnX, returnY, w, h, deepDiveInfo,
|
|
};
|
|
|
|
this.tweens.add({
|
|
targets: cardHolder, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2,
|
|
duration: 300, ease: 'Cubic.easeOut',
|
|
onComplete: () => this.revealZones(deepDiveInfo, cardHolder),
|
|
});
|
|
}
|
|
|
|
// Precomputes each visible zone's title/body text (so its real rendered
|
|
// height is known), stacks each side's pills top-to-bottom around the
|
|
// card's vertical center with a fixed gap so they can never overlap
|
|
// (however many zones apply to this card), then staggers the reveal.
|
|
revealZones(deepDiveInfo, cardHolder) {
|
|
if (!this._deepDive) return; // closed mid zoom-in
|
|
const def = cardDef(deepDiveInfo.inst);
|
|
const zones = (DEEPDIVE_ZONES[deepDiveInfo.kind] || []).filter((z) => !z.condition || z.condition(def));
|
|
const { w, h } = this._deepDive;
|
|
const pillW = 460, gap = 24;
|
|
const entries = zones.map((zone) => {
|
|
const text = typeof zone.text === 'function' ? zone.text(def) : zone.text;
|
|
const titleTxt = this.add.text(0, 0, zone.title, {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, align: 'center',
|
|
}).setOrigin(0.5).setVisible(false);
|
|
const bodyTxt = this.add.text(0, 0, text, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#f2ead8', align: 'center',
|
|
wordWrap: { width: pillW - 40 },
|
|
}).setOrigin(0.5).setVisible(false);
|
|
const totalH = titleTxt.height + bodyTxt.height + 28;
|
|
return { zone, def, titleTxt, bodyTxt, totalH };
|
|
});
|
|
for (const side of ['left', 'right']) {
|
|
const list = entries.filter((e) => e.zone.side === side);
|
|
const blockH = list.reduce((s, e) => s + e.totalH, 0) + gap * Math.max(0, list.length - 1);
|
|
let cursor = -blockH / 2;
|
|
for (const e of list) {
|
|
e.pillY = cursor + e.totalH / 2;
|
|
cursor += e.totalH + gap;
|
|
}
|
|
}
|
|
entries.forEach((entry, i) => {
|
|
const t = this.time.delayedCall(i * 200, () => this.showZoneCallout(entry, pillW, w, h, cardHolder));
|
|
this._deepDive.zoneTimers.push(t);
|
|
});
|
|
}
|
|
|
|
// Draws one zone's callout box + leader line + tooltip pill, all as
|
|
// children of `cardHolder` so they translate for free with the card and
|
|
// get destroyed for free when the modal tears down.
|
|
showZoneCallout(entry, pillW, w, h, cardHolder) {
|
|
const { zone, def, titleTxt, bodyTxt, totalH, pillY } = entry;
|
|
const r = zone.rect(w, h, def);
|
|
const rcx = r.x + r.w / 2, rcy = r.y + r.h / 2;
|
|
const edgeX = zone.side === 'left' ? rcx - r.w / 2 : rcx + r.w / 2;
|
|
const anchorX = zone.anchorX(w, def);
|
|
|
|
const box = this.add.container(rcx, rcy).setScale(0).setAlpha(0);
|
|
const bg = this.add.graphics();
|
|
bg.lineStyle(3, C.gold, 1);
|
|
bg.strokeRoundedRect(-r.w / 2, -r.h / 2, r.w, r.h, 4);
|
|
box.add(bg);
|
|
cardHolder.add(box);
|
|
this.tweens.add({ targets: box, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
|
|
|
|
const line = this.add.graphics().setAlpha(0);
|
|
line.lineStyle(2, C.gold, 0.9);
|
|
line.lineBetween(edgeX, rcy, anchorX, pillY);
|
|
cardHolder.add(line);
|
|
this.tweens.add({ targets: line, alpha: 1, duration: 150, delay: 80 });
|
|
|
|
titleTxt.setY(-totalH / 2 + titleTxt.height / 2 + 10).setVisible(true);
|
|
bodyTxt.setY(totalH / 2 - bodyTxt.height / 2 - 10).setVisible(true);
|
|
const pillBg = this.add.graphics();
|
|
pillBg.fillStyle(0x120e16, 0.94);
|
|
pillBg.fillRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
|
|
pillBg.lineStyle(1.5, C.gold, 0.8);
|
|
pillBg.strokeRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
|
|
const pillX = zone.side === 'left' ? anchorX - pillW / 2 : anchorX + pillW / 2;
|
|
const pill = this.add.container(pillX, pillY, [pillBg, titleTxt, bodyTxt]).setScale(0.85).setAlpha(0);
|
|
cardHolder.add(pill);
|
|
this.tweens.add({ targets: pill, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut', delay: 160 });
|
|
|
|
this._deepDive.zoneNodes.push(box, line, pill);
|
|
}
|
|
|
|
// afterClose (optional) fires once the card has fully flown back to its
|
|
// original spot and the modal is gone — used by Buy/Attack so the actual
|
|
// action + its animations only start once the card is visually "returned"
|
|
closeDeepDive(afterClose) {
|
|
if (!this._deepDive) return;
|
|
const { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY } = this._deepDive;
|
|
zoneTimers.forEach((t) => t.remove());
|
|
this.tweens.killTweensOf(cardHolder);
|
|
if (zoneNodes.length) this.tweens.add({ targets: zoneNodes, alpha: 0, duration: 150, ease: 'Sine.easeIn' });
|
|
this.tweens.add({
|
|
targets: cardHolder, x: returnX, y: returnY,
|
|
duration: 280, ease: 'Cubic.easeIn',
|
|
onComplete: () => { modal.destroy(); this._deepDive = null; afterClose?.(); },
|
|
});
|
|
}
|
|
|
|
// is this deep-dived card buyable by the human right now? Handles the
|
|
// Outer Rim Pilot specially — legalActions() keys its buy entry on the
|
|
// literal string 'outerrim', not the actual pilot card's own uid.
|
|
deepDiveBuyInfo(inst) {
|
|
if (this.squad.size) return null;
|
|
const legal = this.humanTurnLegal();
|
|
if (!legal) return null;
|
|
if (legal.buys.some((b) => b.uid === inst.uid)) return { uid: inst.uid };
|
|
const orp = this.gs.outerRim[this.gs.outerRim.length - 1];
|
|
if (orp && orp.uid === inst.uid && legal.buys.some((b) => b.uid === 'outerrim')) return { uid: 'outerrim' };
|
|
return null;
|
|
}
|
|
|
|
// is this deep-dived card a legal bounty target for the currently
|
|
// committed squad? rowTargetReachable() alone doesn't confirm the card is
|
|
// actually IN the row (it would also pass for a same-faction unit shown
|
|
// from elsewhere), so check row membership first.
|
|
deepDiveAttackable(inst) {
|
|
if (!this.squad.size || !this.gs.galaxy.row.some((c) => c.uid === inst.uid)) return false;
|
|
return this.rowTargetReachable(inst);
|
|
}
|
|
|
|
deepDiveBuy(buyInfo) {
|
|
const d = this.mode.decision;
|
|
this.closeDeepDive(() => {
|
|
this.sfx(SFX.PURCHASE);
|
|
this.applyDecision(d, { type: 'buy', uid: buyInfo.uid });
|
|
});
|
|
}
|
|
|
|
deepDiveAttack(inst) {
|
|
const d = this.mode.decision;
|
|
this.closeDeepDive(() => {
|
|
const uids = [...this.squad];
|
|
this.squad.clear();
|
|
this.sfx(SFX.SCIFI_LAUNCH);
|
|
this.applyDecision(d, { type: 'attackRow', targetUid: inst.uid, uids });
|
|
});
|
|
}
|
|
|
|
// Instant teardown (no fly-back animation) — used when game state changes
|
|
// out from under a still-open deep dive (e.g. renderAll firing mid-hover).
|
|
forceCloseDeepDive() {
|
|
if (!this._deepDive) return;
|
|
const { modal, cardHolder, zoneTimers } = this._deepDive;
|
|
zoneTimers.forEach((t) => t.remove());
|
|
this.tweens.killTweensOf(cardHolder);
|
|
modal.destroy();
|
|
this._deepDive = null;
|
|
}
|
|
|
|
// ── full re-render ──────────────────────────────────────────────────────────
|
|
renderAll() {
|
|
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
|
|
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
|
|
this.hideHover();
|
|
this.forceCloseDeepDive();
|
|
this.boardLayer.removeAll(true);
|
|
this.rowLayer.removeAll(true);
|
|
this.handLayer.removeAll(true);
|
|
this._rowPos.clear();
|
|
this._playPos.clear();
|
|
if (!this.gs) return;
|
|
this.renderOpponent();
|
|
this.renderGalaxy();
|
|
this.renderForceTrack();
|
|
this.renderHuman();
|
|
this.renderHand();
|
|
}
|
|
|
|
seatName(seat) { return seat === this.humanSeat ? 'you' : (this.opponents[0]?.name || 'Opponent'); }
|
|
|
|
// final resting slot for opponent capital index `i` / in-play index `i` —
|
|
// shared by renderOpponent() and the opponent's card-play deal-in animation
|
|
oppCapitalSlot(i) { return { x: 660 + i * 122, y: 140, w: 108, h: 152 }; }
|
|
oppInPlaySlot(i) { return { x: 1210 + (i % 5) * 96, y: 100 + Math.floor(i / 5) * 88, w: 84, h: 118 }; }
|
|
|
|
renderOpponent() {
|
|
const gs = this.gs;
|
|
const seat = 1 - this.humanSeat;
|
|
const p = gs.players[seat];
|
|
const info = FACTION_INFO[p.faction];
|
|
const g = this.add.graphics();
|
|
const attackable = this.squad.size > 0 && this.myTurnMode();
|
|
g.fillStyle(C.panel, 0.6); g.fillRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12);
|
|
g.lineStyle(2, attackable ? C.bad : C.panelEdge, 1); g.strokeRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12);
|
|
this.boardLayer.add(g);
|
|
if (attackable) {
|
|
this.tweens.add({ targets: g, alpha: 0.5, duration: 420, yoyo: true, repeat: -1 });
|
|
const z = this.add.zone(GAME_WIDTH / 2, 140, GAME_WIDTH - 60, 212).setInteractive({ useHandCursor: true });
|
|
z.on('pointerdown', () => this.launchBaseAttack());
|
|
this.boardLayer.add(z);
|
|
}
|
|
this.boardLayer.add(this.add.text(140, 196, this.opponents[0]?.name || 'Opponent', {
|
|
fontFamily: 'Righteous', fontSize: '17px', color: info.colorHex,
|
|
}).setOrigin(0.5));
|
|
this.boardLayer.add(this.add.text(140, 220, info.label, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
// base
|
|
if (p.base) {
|
|
this.makeBaseCard(392, 140, p.faction, p.base, 300, 190, {
|
|
parent: this.boardLayer, hover: false, showText: true,
|
|
onClick: () => { if (this.squad.size && this.myTurnMode()) this.launchBaseAttack(); else this.showInspectBase(p.faction, p.base); },
|
|
});
|
|
} else {
|
|
this.boardLayer.add(this.add.text(392, 140, 'BASE DESTROYED', {
|
|
fontFamily: 'Righteous', fontSize: '22px', color: C.badHex,
|
|
}).setOrigin(0.5).setAngle(-6));
|
|
}
|
|
this._oppBasePos = { x: 392, y: 140 };
|
|
this.renderOpponentPiles(570, p);
|
|
// capitals
|
|
p.capitals.forEach((e, i) => {
|
|
const slot = this.oppCapitalSlot(i);
|
|
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
|
if (this._pendingCardUids.has(e.card.uid)) return;
|
|
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
|
parent: this.boardLayer, hover: false, damage: e.damage,
|
|
onClick: () => this.showInspect(e.card),
|
|
});
|
|
});
|
|
// opponent cards in play (their turn)
|
|
p.inPlay.forEach((e, i) => {
|
|
const slot = this.oppInPlaySlot(i);
|
|
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
|
if (this._pendingCardUids.has(e.card.uid)) return;
|
|
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
|
parent: this.boardLayer, hover: false, committed: e.committed,
|
|
attackNow: entryAttack(gs, seat, e),
|
|
onClick: () => this.showInspect(e.card),
|
|
});
|
|
});
|
|
// end-of-turn discard ghosts: a discarded in-play card stays put right up
|
|
// until its own fly-to-discard animation begins
|
|
for (const [uid, ghost] of this._endTurnGhosts) {
|
|
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
|
|
const pos = this._endTurnFromPos.get(uid);
|
|
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 84, 118, { parent: this.boardLayer, hover: false });
|
|
}
|
|
// counters
|
|
const cx = GAME_WIDTH - 220;
|
|
const lines = [
|
|
`Hand ${p.hand.length} Deck ${p.deck.length}`,
|
|
`Discard ${p.discard.length}`,
|
|
`Resources ${p.resources}`,
|
|
];
|
|
this.boardLayer.add(this.add.text(cx, 95, lines.join('\n'), {
|
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.muted, lineSpacing: 7,
|
|
}));
|
|
// bases remaining / lost
|
|
this.renderBasePips(cx, 193, p, info);
|
|
if (gs.turnSeat === seat && !gs.over) {
|
|
this.boardLayer.add(this.add.text(GAME_WIDTH / 2, 48, '— THEIR TURN —', {
|
|
fontFamily: 'Righteous', fontSize: '15px', color: info.colorHex,
|
|
}).setOrigin(0.5));
|
|
}
|
|
}
|
|
|
|
// small face-down hand fan + draw/discard pile icons, shown just right of the
|
|
// portrait so pile sizes are visible at a glance without opening any panel
|
|
renderOpponentPiles(x, p) {
|
|
const pw = 30, ph = 42;
|
|
// while the old hand is still discarding (each ghost clears the instant
|
|
// its own fly-away animation begins), show that shrinking count instead
|
|
// of the new hand's — which only starts counting up once every old ghost
|
|
// is gone
|
|
const oldHandGhosts = [...this._endTurnGhosts.values()].filter((g) => g.zone === 'hand' && g.seat === p.seat).length;
|
|
const pendingInHand = p.hand.filter((c) => this._pendingCardUids.has(c.uid)).length;
|
|
const visibleCount = oldHandGhosts > 0 ? oldHandGhosts : p.hand.length - pendingInHand;
|
|
const shown = Math.min(visibleCount, 4);
|
|
const fanY = 72;
|
|
for (let i = 0; i < shown; i++) {
|
|
const off = i - (shown - 1) / 2;
|
|
this.makeCardBack(x + off * 7, fanY, pw, ph, this.boardLayer).setAngle(off * 7);
|
|
}
|
|
this.boardLayer.add(this.add.text(x, fanY + ph / 2 + 11, `HAND ${visibleCount}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
const pile = (y, count, label) => {
|
|
if (count > 0) {
|
|
this.makeCardBack(x, y, pw, ph, this.boardLayer);
|
|
} else {
|
|
const g = this.add.graphics();
|
|
g.lineStyle(2, 0x2a3c66, 0.6);
|
|
g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 6);
|
|
this.boardLayer.add(g);
|
|
}
|
|
this.boardLayer.add(this.add.text(x, y + ph / 2 + 11, `${label} ${count}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
};
|
|
pile(132, this.visibleDeckCount(p), 'DRAW');
|
|
pile(192, this.visibleDiscardCount(p), 'DISCARD');
|
|
}
|
|
|
|
// a bought card is already in p.discard state-wise the instant it's
|
|
// purchased, but its reveal-then-stow animation hasn't landed yet — lag the
|
|
// displayed discard count by however many of its cards are still pending
|
|
visibleDiscardCount(p) {
|
|
if (!this._pendingDiscardUids.size) return p.discard.length;
|
|
return p.discard.length - p.discard.filter((c) => this._pendingDiscardUids.has(c.uid)).length;
|
|
}
|
|
|
|
// same idea for the draw pile — only relevant for the rare "buy it and
|
|
// topdeck it" ability, which sends a bought card to the deck instead
|
|
visibleDeckCount(p) {
|
|
if (!this._pendingDeckUids.size) return p.deck.length;
|
|
return p.deck.length - p.deck.filter((c) => this._pendingDeckUids.has(c.uid)).length;
|
|
}
|
|
|
|
renderBasePips(x, y, p, info) {
|
|
this.boardLayer.add(this.add.text(x, y - 22, `Bases lost ${p.lostBases}/${this.gs.meta.basesToWin}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
|
|
}));
|
|
for (let i = 0; i < this.gs.meta.basesToWin; i++) {
|
|
const gg = this.add.graphics();
|
|
const lost = i < p.lostBases;
|
|
gg.fillStyle(lost ? C.bad : 0x22304f, 1);
|
|
gg.fillCircle(x + 10 + i * 30, y + 8, 10);
|
|
if (lost) { gg.lineStyle(2, 0xffffff, 0.7); gg.lineBetween(x + 3 + i * 30, y + 1, x + 17 + i * 30, y + 15); }
|
|
this.boardLayer.add(gg);
|
|
}
|
|
}
|
|
|
|
// final resting slot for row index `i` given a card's faction — shared by
|
|
// renderGalaxy() and the row-refill deal-in animation
|
|
galaxyRowSlot(i, faction, humanFaction) {
|
|
const rowX0 = 510, cw = 158, ch = 222, y = 430;
|
|
const rowNudge = faction === 'neutral' ? 0 : faction === humanFaction ? 12 : -12;
|
|
return { x: rowX0 + i * (cw + 14), y: y + rowNudge, w: cw, h: ch };
|
|
}
|
|
|
|
// ── galaxy band ─────────────────────────────────────────────────────────────
|
|
renderGalaxy() {
|
|
const gs = this.gs;
|
|
const y = 430;
|
|
const cw = 158, ch = 222;
|
|
// discard + deck
|
|
if (gs.galaxy.discard.length) {
|
|
const top = gs.galaxy.discard[gs.galaxy.discard.length - 1];
|
|
this.makeCard(160, y, top, cw * 0.82, ch * 0.82, { parent: this.rowLayer, hover: false, onClick: () => this.showInspect(top) });
|
|
this.rowLayer.add(this.add.text(160, y + ch / 2 + 4, `discard ${gs.galaxy.discard.length}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
} else {
|
|
this.rowLayer.add(this.add.text(160, y, 'GALAXY\nDISCARD', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#2a3c66', align: 'center',
|
|
}).setOrigin(0.5));
|
|
}
|
|
this.makeCardBack(330, y, cw * 0.9, ch * 0.9, this.rowLayer, `${gs.galaxy.deck.length}`);
|
|
// the row
|
|
const rowX0 = 510;
|
|
const legal = this.humanTurnLegal();
|
|
const humanFaction = gs.players[this.humanSeat].faction;
|
|
// stable per-card slot assignment: a card keeps its screen slot for as
|
|
// long as it's in the row; when it leaves, that slot frees up for
|
|
// whatever card claims it next — so buying (or losing) a row card never
|
|
// shifts its neighbors, and a refill naturally lands in the same slot
|
|
// its predecessor vacated. A bought/bountied card's slot is held open
|
|
// (via _rowGhosts) until its own animation actually reaches/removes it,
|
|
// so nothing else can claim it while it's still visible.
|
|
const liveUids = new Set(gs.galaxy.row.map((c) => c.uid));
|
|
for (const uid of [...this._rowSlots.keys()]) {
|
|
if (!liveUids.has(uid) && !this._rowGhosts.has(uid)) this._rowSlots.delete(uid);
|
|
}
|
|
const usedSlots = new Set(this._rowSlots.values());
|
|
for (const card of gs.galaxy.row) {
|
|
if (this._rowSlots.has(card.uid)) continue;
|
|
let slot = 0;
|
|
while (usedSlots.has(slot)) slot++;
|
|
if (slot >= this.gs.meta.galaxyRowSize) continue; // no free slot yet
|
|
this._rowSlots.set(card.uid, slot);
|
|
usedSlots.add(slot);
|
|
}
|
|
gs.galaxy.row.forEach((card) => {
|
|
const i = this._rowSlots.get(card.uid);
|
|
if (i == null) return;
|
|
const def = cardDef(card);
|
|
const slot = this.galaxyRowSlot(i, def.faction, humanFaction);
|
|
this._rowPos.set(card.uid, { x: slot.x, y: slot.y });
|
|
if (this._pendingCardUids.has(card.uid)) return;
|
|
const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === card.uid);
|
|
const targetable = this.squad.size > 0 && this.rowTargetReachable(card);
|
|
const freeTarget = this.mode.type === 'target' && ['discardRow', 'freePurchase', 'destroyCapital'].includes(this.mode.decision?.op)
|
|
&& (this.mode.candidates || []).some((c) => c.uid === card.uid);
|
|
this.makeCard(slot.x, slot.y, card, slot.w, slot.h, {
|
|
parent: this.rowLayer, showText: true,
|
|
highlightBuy: buyable, highlightTarget: targetable, highlightGold: freeTarget,
|
|
onClick: () => this.onRowClicked(card),
|
|
});
|
|
});
|
|
// ghosts: a just-bought or just-bountied card still occupies its vacated
|
|
// slot until its own animation actually reaches/removes it
|
|
for (const [uid, id] of this._rowGhosts) {
|
|
const gi = this._rowSlots.get(uid);
|
|
if (gi == null) continue;
|
|
const def = cardDef(id);
|
|
const slot = this.galaxyRowSlot(gi, def.faction, humanFaction);
|
|
this._rowPos.set(uid, { x: slot.x, y: slot.y });
|
|
this.makeCard(slot.x, slot.y, { uid, id }, slot.w, slot.h, { parent: this.rowLayer, hover: false, showText: true });
|
|
}
|
|
// Outer Rim Pilot stack
|
|
if (gs.outerRim.length) {
|
|
const orp = getData().json.outerRimPilot;
|
|
const x = rowX0 + 6 * (cw + 14) + 20;
|
|
const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === 'outerrim');
|
|
this.makeCard(x, y, gs.outerRim[gs.outerRim.length - 1], cw, ch, {
|
|
parent: this.rowLayer, showText: true, highlightBuy: buyable,
|
|
onClick: () => this.onOuterRimClicked(),
|
|
});
|
|
this.rowLayer.add(this.add.text(x, y + ch / 2 + 4, `${gs.outerRim.length} left`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
this._orpPos = { x, y };
|
|
}
|
|
}
|
|
|
|
renderForceTrack() {
|
|
const gs = this.gs;
|
|
const cx = GAME_WIDTH / 2, y = 590;
|
|
const cellW = 66, cellH = 34;
|
|
const total = FORCE_MAX * 2 + 1;
|
|
const x0 = cx - (total * cellW) / 2;
|
|
const g = this.add.graphics();
|
|
for (let i = 0; i < total; i++) {
|
|
const v = i - FORCE_MAX; // -3 .. +3 (empire … rebel)
|
|
const isMark = gs.force === v;
|
|
g.fillStyle(v < 0 ? 0x14213d : v > 0 ? 0x3d1a16 : 0x1a1a22, 1);
|
|
g.fillRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6);
|
|
g.lineStyle(isMark ? 3 : 1, isMark ? 0xffffff : C.panelEdge, isMark ? 1 : 0.7);
|
|
g.strokeRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6);
|
|
}
|
|
this.boardLayer.add(g);
|
|
const mx = x0 + (gs.force + FORCE_MAX) * cellW + cellW / 2;
|
|
const marker = this.add.graphics();
|
|
marker.fillStyle(0xffffff, 1); marker.fillCircle(mx, y, 9);
|
|
marker.lineStyle(2, C.gold, 1); marker.strokeCircle(mx, y, 9);
|
|
this.boardLayer.add(marker);
|
|
this._forceMarkerPos = { x: mx, y };
|
|
this.boardLayer.add(this.add.text(x0 - 16, y, FACTION_INFO.empire.symbol, { fontSize: '26px', color: FACTION_INFO.empire.colorHex }).setOrigin(1, 0.5));
|
|
this.boardLayer.add(this.add.text(x0 + total * cellW + 16, y, FACTION_INFO.rebel.symbol, { fontSize: '26px', color: FACTION_INFO.rebel.colorHex }).setOrigin(0, 0.5));
|
|
const label = gs.force === 0 ? 'THE FORCE IS BALANCED'
|
|
: `THE FORCE IS WITH THE ${(gs.force > 0 ? 'REBELS' : 'EMPIRE')}${Math.abs(gs.force) === FORCE_MAX ? ' — +1 RESOURCE' : ''}`;
|
|
const labelTxt = this.add.text(cx, y + 34, label, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: gs.force === 0 ? C.muted : (gs.force > 0 ? FACTION_INFO.rebel.colorHex : FACTION_INFO.empire.colorHex),
|
|
}).setOrigin(0.5);
|
|
this.boardLayer.add(labelTxt);
|
|
this._forceLabelRight = { x: cx + labelTxt.width / 2, y: y + 34 };
|
|
}
|
|
|
|
// ── human area ──────────────────────────────────────────────────────────────
|
|
// final resting slot for the human's own capital index `i` / in-play index
|
|
// `i` of a row with `unitsLength` cards — shared by renderHuman() and the
|
|
// human's own card-play deal-in animation
|
|
humanCapitalSlot(i) { return { x: 620 + i * 128, y: 730, w: 116, h: 162 }; }
|
|
humanInPlaySlot(i, unitsLength) {
|
|
const uw = 122, uh = 170;
|
|
const pitch = Math.min(uw + 10, 620 / Math.max(1, unitsLength));
|
|
const x0 = 1180 - ((unitsLength - 1) * pitch) / 2;
|
|
return { x: x0 + i * pitch, y: 730, w: uw, h: uh };
|
|
}
|
|
|
|
renderHuman() {
|
|
const gs = this.gs;
|
|
const seat = this.humanSeat;
|
|
const p = gs.players[seat];
|
|
const info = FACTION_INFO[p.faction];
|
|
const legal = this.humanTurnLegal();
|
|
|
|
this.boardLayer.add(this.add.text(140, 878, info.label, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: info.colorHex,
|
|
}).setOrigin(0.5));
|
|
// resources
|
|
this.boardLayer.add(this.add.text(140, 912, `▣ ${p.resources}`, {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: '#e8c860',
|
|
}).setOrigin(0.5));
|
|
this.renderBasePips(70, 975, p, info);
|
|
|
|
// base — vertically centered between the force track's balance label
|
|
// (~y=624) and the informational prompt text (~y=845) just below
|
|
const playRowY = 730;
|
|
if (p.base) {
|
|
const useBase = legal && legal.abilities.find((a) => a.zone === 'base');
|
|
this.makeBaseCard(392, playRowY, p.faction, p.base, 300, 190, {
|
|
parent: this.boardLayer, hover: false, showText: true,
|
|
onClick: () => this.showInspectBase(p.faction, p.base),
|
|
});
|
|
this._myBasePos = { x: 392, y: playRowY };
|
|
if (useBase) this.addUsePill(392, playRowY + 102, () => this.beginAbility({ zone: 'base', uid: 'base' }));
|
|
} else {
|
|
this.boardLayer.add(this.add.text(392, playRowY, 'CHOOSE A NEW BASE…', {
|
|
fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
}
|
|
|
|
// capitals
|
|
p.capitals.forEach((e, i) => {
|
|
const slot = this.humanCapitalSlot(i);
|
|
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
|
if (this._pendingCardUids.has(e.card.uid)) return;
|
|
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
|
|
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
|
|
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
|
parent: this.boardLayer, hover: false, damage: e.damage,
|
|
selected: this.squad.has(e.card.uid), committed: e.committed,
|
|
attackNow: entryAttack(gs, seat, e),
|
|
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
|
|
});
|
|
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'capital', uid: e.card.uid }));
|
|
});
|
|
|
|
// units in play
|
|
const units = p.inPlay;
|
|
units.forEach((e, i) => {
|
|
const slot = this.humanInPlaySlot(i, units.length);
|
|
this._playPos.set(e.card.uid, { x: slot.x, y: slot.y });
|
|
if (this._pendingCardUids.has(e.card.uid)) return;
|
|
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
|
|
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
|
|
this.makeCard(slot.x, slot.y, e.card, slot.w, slot.h, {
|
|
parent: this.boardLayer, hover: false,
|
|
selected: this.squad.has(e.card.uid), committed: e.committed,
|
|
attackNow: entryAttack(gs, seat, e),
|
|
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
|
|
});
|
|
if (canUse) this.addUsePill(slot.x, this.usePillY(slot.y, slot.h), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
|
|
});
|
|
// end-of-turn discard ghosts: a discarded in-play card stays put right up
|
|
// until its own fly-to-discard animation begins
|
|
for (const [uid, ghost] of this._endTurnGhosts) {
|
|
if (ghost.zone !== 'inPlay' || ghost.seat !== seat) continue;
|
|
const pos = this._endTurnFromPos.get(uid);
|
|
if (pos) this.makeCard(pos.x, pos.y, { uid, id: ghost.id }, 122, 170, { parent: this.boardLayer, hover: false });
|
|
}
|
|
if (!units.length && !p.capitals.length) {
|
|
this.boardLayer.add(this.add.text(1180, playRowY, 'cards you play land here', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66',
|
|
}).setOrigin(0.5));
|
|
}
|
|
|
|
// squad readout — sits just right of the force track's balance label
|
|
if (this.squad.size) {
|
|
const total = this.squadAttack();
|
|
const pos = this._forceLabelRight ?? { x: GAME_WIDTH / 2, y: 624 };
|
|
this.boardLayer.add(this.add.text(pos.x + 24, pos.y, `⚔ ${total} committed — strike the enemy base or a marked bounty`, {
|
|
fontFamily: 'Righteous', fontSize: '18px', color: C.badHex,
|
|
}).setOrigin(0, 0.5));
|
|
}
|
|
}
|
|
|
|
// Y for a USE pill that overlays the lower part of a board card, sitting
|
|
// just below its art image (mirrors makeCard's own art-window math for a
|
|
// non-showText card, where the art window runs the full lower half).
|
|
usePillY(cardY, h) {
|
|
const plateH = Math.max(15, h * 0.12);
|
|
const artBottom = plateH + 8;
|
|
return cardY + artBottom + 10;
|
|
}
|
|
|
|
addUsePill(x, y, cb) {
|
|
const b = this.add.container(x, y);
|
|
const bg = this.add.graphics();
|
|
bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-34, -12, 68, 24, 12);
|
|
b.add(bg);
|
|
b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#0a0f1f' }).setOrigin(0.5));
|
|
b.setSize(68, 24);
|
|
b.setInteractive({ useHandCursor: true });
|
|
b.on('pointerdown', () => { if (!this.busy) cb(); });
|
|
this.tweens.add({ targets: b, alpha: 0.65, duration: 520, yoyo: true, repeat: -1 });
|
|
this.boardLayer.add(b);
|
|
}
|
|
|
|
renderHand() {
|
|
const gs = this.gs;
|
|
const p = gs.players[this.humanSeat];
|
|
const y = 985;
|
|
// draw pile sits just under the human's base card (base is 392,730 size 300x190)
|
|
this.renderHandPile(392, 911, this.visibleDeckCount(p), 'DRAW');
|
|
this.renderHandPile(GAME_WIDTH - 205, y, this.visibleDiscardCount(p), 'DISCARD');
|
|
// end-of-turn discard ghosts: a discarded hand card stays put in its
|
|
// virtual old-hand slot right up until its own fly-to-discard animation
|
|
// begins (the real hand has already been replaced by then)
|
|
for (const [uid, ghost] of this._endTurnGhosts) {
|
|
if (ghost.zone !== 'hand' || ghost.seat !== this.humanSeat) continue;
|
|
const layout = this._endTurnHandLayout.get(uid);
|
|
if (layout) this.makeCard(this.humanHandSlotX(layout.idx, layout.total), y, { uid, id: ghost.id }, 148, 206, { parent: this.handLayer, hover: false, showText: true });
|
|
}
|
|
if (!p.hand.length) return;
|
|
const cw = 148, chh = 206;
|
|
const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length));
|
|
let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2;
|
|
for (const inst of p.hand) {
|
|
const cardX = x;
|
|
x += pitch;
|
|
if (this._pendingCardUids.has(inst.uid)) continue;
|
|
const playable = this.myTurnMode();
|
|
// forced discard (an opponent's effect) reads as something being done
|
|
// TO the player, so it gets its own pulsing red overlay rather than the
|
|
// gold "you get to pick" cue used for a voluntary exile-as-cost choice
|
|
const forcedDiscard = this.mode.type === 'oppDiscard';
|
|
const exileCost = this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
|
|
&& (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid);
|
|
const discardable = forcedDiscard || exileCost;
|
|
this.makeCard(cardX, y, inst, cw, chh, {
|
|
parent: this.handLayer, showText: true,
|
|
highlightGold: exileCost,
|
|
highlightDiscard: forcedDiscard,
|
|
onClick: () => this.onHandClicked(inst),
|
|
});
|
|
if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9);
|
|
// Grand Moff Tarkin's borrowed card — flag that it exiles at end of turn.
|
|
if (p.tempExileUids?.includes(inst.uid)) {
|
|
const tag = this.add.container(cardX, y - chh / 2 + 12);
|
|
const tg = this.add.graphics();
|
|
tg.fillStyle(0x3a1f52, 0.95); tg.fillRoundedRect(-58, -9, 116, 18, 5);
|
|
tg.lineStyle(1, 0xb06bd8, 1); tg.strokeRoundedRect(-58, -9, 116, 18, 5);
|
|
tag.add(tg);
|
|
tag.add(this.add.text(0, 0, 'EXILES AT TURN END', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '10px', color: '#d9b8f0',
|
|
}).setOrigin(0.5));
|
|
this.handLayer.add(tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
// final resting x for hand slot `index` of a hand with `handLength` cards —
|
|
// shared by renderHand() and the deal-in animation so a card's flight target
|
|
// always matches where it will actually land
|
|
humanHandSlotX(index, handLength) {
|
|
const cw = 148;
|
|
const pitch = Math.min(cw + 8, 1250 / Math.max(1, handLength));
|
|
const x0 = GAME_WIDTH / 2 - ((handLength - 1) * pitch) / 2;
|
|
return x0 + index * pitch;
|
|
}
|
|
|
|
// small draw/discard pile shown beside the human hand: card back + count when
|
|
// non-empty, dashed outline placeholder when empty
|
|
renderHandPile(x, y, count, label) {
|
|
const w = 90, h = 124;
|
|
this.handLayer.add(this.add.text(x, y - h / 2 - 14, label, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
if (count > 0) {
|
|
this.makeCardBack(x, y, w, h, this.handLayer, `${count}`);
|
|
} else {
|
|
const g = this.add.graphics();
|
|
g.lineStyle(2, 0x2a3c66, 0.6);
|
|
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 7);
|
|
this.handLayer.add(g);
|
|
}
|
|
}
|
|
|
|
// ── interaction helpers ─────────────────────────────────────────────────────
|
|
myTurnMode() { return this.mode.type === 'turn'; }
|
|
humanTurnLegal() {
|
|
if (!this.gs || !this.myTurnMode()) return null;
|
|
return legalActions(this.gs, this.humanSeat);
|
|
}
|
|
|
|
squadAttack(vsRow = false) {
|
|
const p = this.gs.players[this.humanSeat];
|
|
let total = 0;
|
|
for (const uid of this.squad) {
|
|
const e = p.inPlay.find((x) => x.card.uid === uid) || p.capitals.find((x) => x.card.uid === uid);
|
|
if (e) total += entryAttack(this.gs, this.humanSeat, e, { vsRow });
|
|
}
|
|
return total;
|
|
}
|
|
|
|
rowTargetReachable(card) {
|
|
if (!this.myTurnMode() || !this.squad.size) return false;
|
|
const def = cardDef(card);
|
|
const p = this.gs.players[this.humanSeat];
|
|
if (def.faction === 'neutral' || def.faction === p.faction || def.type !== 'unit' || def.target == null) return false;
|
|
// capitals cannot join bounty hunts
|
|
for (const uid of this.squad) if (p.capitals.some((e) => e.card.uid === uid)) return false;
|
|
return this.squadAttack(true) >= def.target;
|
|
}
|
|
|
|
toggleSquad(uid) {
|
|
if (this.squad.has(uid)) this.squad.delete(uid);
|
|
else this.squad.add(uid);
|
|
this.sfx(SFX.CARD_SHOW);
|
|
this.renderAll();
|
|
}
|
|
|
|
launchBaseAttack() {
|
|
if (!this.myTurnMode() || !this.squad.size) return;
|
|
const o = this.gs.players[1 - this.humanSeat];
|
|
const order = o.capitals.slice()
|
|
.sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage))
|
|
.map((e) => e.card.uid);
|
|
const uids = [...this.squad];
|
|
this.squad.clear();
|
|
this.sfx(SFX.SCIFI_LAUNCH);
|
|
this.applyDecision(this.mode.decision, { type: 'attackBase', uids, capitalOrder: order });
|
|
}
|
|
|
|
onRowClicked(card) {
|
|
const m = this.mode;
|
|
if (m.type === 'target') {
|
|
const ref = (m.candidates || []).find((c) => c.uid === card.uid);
|
|
if (ref) { this.applyDecision(m.decision, ref); return; }
|
|
}
|
|
if (this.myTurnMode()) {
|
|
if (this.squad.size && this.rowTargetReachable(card)) {
|
|
const uids = [...this.squad];
|
|
this.squad.clear();
|
|
this.sfx(SFX.SCIFI_LAUNCH);
|
|
this.applyDecision(this.mode.decision, { type: 'attackRow', targetUid: card.uid, uids });
|
|
return;
|
|
}
|
|
const legal = this.humanTurnLegal();
|
|
if (!this.squad.size && legal.buys.some((b) => b.uid === card.uid)) {
|
|
this.sfx(SFX.PURCHASE);
|
|
this.applyDecision(this.mode.decision, { type: 'buy', uid: card.uid });
|
|
return;
|
|
}
|
|
}
|
|
this.showInspect(card);
|
|
}
|
|
|
|
onOuterRimClicked() {
|
|
if (this.myTurnMode() && !this.squad.size) {
|
|
const legal = this.humanTurnLegal();
|
|
if (legal.buys.some((b) => b.uid === 'outerrim')) {
|
|
this.sfx(SFX.PURCHASE);
|
|
this.applyDecision(this.mode.decision, { type: 'buy', uid: 'outerrim' });
|
|
return;
|
|
}
|
|
}
|
|
const orp = this.gs.outerRim[this.gs.outerRim.length - 1];
|
|
if (orp) this.showInspect(orp);
|
|
}
|
|
|
|
onHandClicked(inst) {
|
|
const m = this.mode;
|
|
if (m.type === 'oppDiscard') {
|
|
this.sfx(SFX.CARD_PLACE);
|
|
this.applyDecision(m.decision, inst.uid);
|
|
return;
|
|
}
|
|
if (m.type === 'target' && m.decision?.op === 'exileCards') {
|
|
const ref = (m.candidates || []).find((c) => c.zone === 'hand' && c.uid === inst.uid);
|
|
if (ref) { this.applyDecision(m.decision, ref); return; }
|
|
}
|
|
if (this.myTurnMode()) {
|
|
this.sfx(SFX.CARD_PLACE);
|
|
this.applyDecision(m.decision, { type: 'play', uid: inst.uid });
|
|
return;
|
|
}
|
|
this.showInspect(inst);
|
|
}
|
|
|
|
beginAbility(target) {
|
|
const d = this.mode.decision;
|
|
const p = this.gs.players[this.humanSeat];
|
|
const def = target.zone === 'base' ? baseDef(p.faction, p.base.id)
|
|
: cardDef((p.inPlay.find((e) => e.card.uid === target.uid) || p.capitals.find((e) => e.card.uid === target.uid)).card);
|
|
if (def.ability?.cost?.exileFromHand || def.ability?.cost?.discardFromHand) {
|
|
// pick the cost card first
|
|
const verb = def.ability.cost.exileFromHand ? 'exile' : 'discard';
|
|
this.showHandPickModal(`Choose a card to ${verb} as the cost`, (uid) => {
|
|
this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid, costUid: uid });
|
|
});
|
|
return;
|
|
}
|
|
this.sfx(SFX.SCIFI_REVEAL ?? SFX.CARD_SHOW);
|
|
this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid });
|
|
}
|
|
|
|
// ── pump: run engine until human input is required ─────────────────────────
|
|
pump() {
|
|
if (!this.gs || this.busy) return;
|
|
if (this.gs.over) { this.onGameOver(); return; }
|
|
const d = pendingDecision(this.gs);
|
|
if (!d) return;
|
|
if (d.seat !== this.humanSeat) {
|
|
this.setPrompt(`${this.opponents[0]?.name || 'Opponent'} is plotting…`);
|
|
this.busy = true;
|
|
this.time.delayedCall(nextThinkDelay(this.aiSkill), () => {
|
|
this.busy = false;
|
|
if (this.gs.over) { this.onGameOver(); return; }
|
|
const nd = pendingDecision(this.gs);
|
|
if (!nd || nd.seat === this.humanSeat) { this.pump(); return; }
|
|
const choice = decide(publicView(this.gs, nd.seat), nd, this.aiSkill);
|
|
this.applyDecision(nd, choice);
|
|
});
|
|
return;
|
|
}
|
|
this.enterHumanMode(d);
|
|
}
|
|
|
|
applyDecision(d, choice) {
|
|
// snapshot the attacking squad's board positions here (not just in the
|
|
// human click handlers) since AI-driven attackRow/attackBase choices are
|
|
// built directly in SWDBGAI.js and dispatched straight into this method,
|
|
// bypassing onRowClicked()/launchBaseAttack() entirely — _playPos is still
|
|
// valid here, as nothing re-renders between a decision being made and
|
|
// applyDecision running
|
|
if (choice?.type === 'attackRow' || choice?.type === 'attackBase') {
|
|
this._pendingAttackFrom = (choice.uids || []).map((uid) => this._playPos.get(uid)).filter(Boolean);
|
|
}
|
|
try {
|
|
switch (d.kind) {
|
|
case 'turn': actTurn(this.gs, d.seat, choice); break;
|
|
case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break;
|
|
case 'target': actChooseTarget(this.gs, d.seat, choice); break;
|
|
case 'oppDiscard': actOppDiscard(this.gs, d.seat, choice); break;
|
|
case 'oppChoice': actOppChoice(this.gs, d.seat, choice); break;
|
|
case 'chooseOption': actChooseOption(this.gs, d.seat, choice); break;
|
|
default: break;
|
|
}
|
|
} catch (err) {
|
|
console.error('swdbg action rejected:', err);
|
|
this.mode = { type: 'idle' };
|
|
this.renderAll();
|
|
this.pump();
|
|
return;
|
|
}
|
|
this.mode = { type: 'idle' };
|
|
this.clearActionButtons();
|
|
this.closeModal();
|
|
this.playEvents(takeEvents(this.gs));
|
|
}
|
|
|
|
enterHumanMode(d) {
|
|
this.clearActionButtons();
|
|
switch (d.kind) {
|
|
case 'turn': {
|
|
this.mode = { type: 'turn', decision: d };
|
|
const legal = legalActions(this.gs, this.humanSeat);
|
|
const p = this.gs.players[this.humanSeat];
|
|
if (p.hand.length) this.setPrompt('Your turn — play cards, buy, and attack');
|
|
else if (legal.buys.length || legal.abilities.length || this.squad.size) this.setPrompt('Buy from the galaxy row, use abilities, or attack');
|
|
else this.setPrompt('Attack with your forces or end your turn');
|
|
if (p.hand.length) this.addActionButton('Play All', () => this.applyDecision(d, { type: 'playAll' }));
|
|
this.addActionButton('End Turn', () => { this.squad.clear(); this.applyDecision(d, { type: 'endTurn' }); });
|
|
break;
|
|
}
|
|
case 'chooseBase': {
|
|
this.mode = { type: 'chooseBase', decision: d };
|
|
this.showBaseChoiceModal(d);
|
|
break;
|
|
}
|
|
case 'target': {
|
|
this.mode = { type: 'target', decision: d, candidates: d.candidates };
|
|
this.enterTargetMode(d);
|
|
break;
|
|
}
|
|
case 'oppDiscard': {
|
|
this.mode = { type: 'oppDiscard', decision: d };
|
|
this.setPrompt('Enemy pressure! Choose a card to discard');
|
|
break;
|
|
}
|
|
case 'oppChoice': {
|
|
this.mode = { type: 'oppChoice', decision: d };
|
|
this.showOppChoiceModal(d);
|
|
break;
|
|
}
|
|
case 'chooseOption': {
|
|
this.mode = { type: 'chooseOption', decision: d };
|
|
this.showOptionModal(d);
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
this.renderAll();
|
|
}
|
|
|
|
enterTargetMode(d) {
|
|
switch (d.op) {
|
|
case 'discardRow':
|
|
this.setPrompt('Choose a galaxy-row card to discard — or Skip');
|
|
if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null));
|
|
break;
|
|
case 'freePurchase':
|
|
this.setPrompt('Choose a card to take for free');
|
|
break;
|
|
case 'destroyCapital':
|
|
this.setPrompt('Choose a capital ship to destroy');
|
|
this.showCapitalTargetModal(d);
|
|
break;
|
|
case 'exileCards':
|
|
this.setPrompt('Exile a card from your hand or discard — or Skip');
|
|
if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null));
|
|
this.addActionButton('From discard…', () => this.showDiscardPickModal(d));
|
|
break;
|
|
case 'recoverDiscard':
|
|
this.showRecoverModal(d);
|
|
break;
|
|
case 'lookTop':
|
|
this.showPeekModal(d, 'Top of the galaxy deck', 'Leave on top', 'Discard it');
|
|
break;
|
|
case 'revealTopDiscard':
|
|
this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it');
|
|
break;
|
|
case 'dealDamage':
|
|
this.setPrompt('Choose where the damage lands');
|
|
this.showDealDamageModal(d);
|
|
break;
|
|
case 'buyGalaxyDiscard':
|
|
this.showGalaxyDiscardModal(d);
|
|
break;
|
|
case 'takeRowTemp':
|
|
this.setPrompt('Choose a galaxy-row card of your faction to add to your hand');
|
|
if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null));
|
|
break;
|
|
case 'lookTopSwap':
|
|
this.showLookTopSwapModal(d);
|
|
break;
|
|
case 'oppTopdeck':
|
|
this.showOppTopdeckModal(d);
|
|
break;
|
|
default:
|
|
// Fallback: auto-pick the first candidate so nothing soft-locks.
|
|
this.applyDecision(d, d.candidates[0] || null);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── modals ──────────────────────────────────────────────────────────────────
|
|
modalRoot() {
|
|
this.closeModal();
|
|
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
|
|
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62);
|
|
dim.setInteractive();
|
|
root.add(dim);
|
|
this._modal = root;
|
|
return root;
|
|
}
|
|
closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } for (const b of this._modalButtons || []) b.destroy(); this._modalButtons = []; }
|
|
modalButton(x, y, label, cb, opts = {}) {
|
|
const b = new Button(this, x, y, label, cb, { width: opts.width || 260, fontSize: opts.fontSize || 19, variant: opts.variant });
|
|
b.setDepth(DEPTH.overlay + 1);
|
|
this._modalButtons = this._modalButtons || [];
|
|
this._modalButtons.push(b);
|
|
this.guardFreshButton(b);
|
|
return b;
|
|
}
|
|
|
|
// Button's onClick fires on 'pointerup', while everything else in this game
|
|
// (cards, the USE pill) fires on 'pointerdown'. When a popup opens
|
|
// synchronously from a pointerdown handler (e.g. clicking "USE") while the
|
|
// mouse is still physically held down, that same click's eventual release
|
|
// would otherwise land on a freshly-created popup button and fire it
|
|
// immediately. Swallow just that one pending release before arming it.
|
|
guardFreshButton(b) {
|
|
if (!this.input.activePointer.isDown) return;
|
|
b.input.enabled = false;
|
|
this.input.once('pointerup', () => { if (b.input) b.input.enabled = true; });
|
|
}
|
|
|
|
showBaseChoiceModal(d) {
|
|
const root = this.modalRoot();
|
|
const p = this.gs.players[this.humanSeat];
|
|
root.add(this.add.text(GAME_WIDTH / 2, 300, 'YOUR BASE HAS FALLEN — CHOOSE THE NEXT', {
|
|
fontFamily: 'Righteous', fontSize: '30px', color: C.text,
|
|
}).setOrigin(0.5));
|
|
const n = d.options.length;
|
|
const pitch = Math.min(360, 1500 / Math.max(1, n));
|
|
const x0 = GAME_WIDTH / 2 - ((n - 1) * pitch) / 2;
|
|
d.options.forEach((id, i) => {
|
|
this.makeBaseCard(x0 + i * pitch, 560, p.faction, { id, damage: 0 }, 330, 210, {
|
|
parent: root, hover: false, showText: true, highlight: true,
|
|
onClick: () => this.applyDecision(d, id),
|
|
});
|
|
});
|
|
}
|
|
|
|
showOppChoiceModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
|
root.add(this.add.text(cx, cy - 120, 'ENEMY ULTIMATUM', {
|
|
fontFamily: 'Righteous', fontSize: '30px', color: C.badHex,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text,
|
|
}).setOrigin(0.5));
|
|
const label = (o) => {
|
|
if (o.kind === 'discardSelf') return 'Discard a card from your hand';
|
|
if (o.kind === 'giveForce') return `Let them gain ${o.n || 1} Force`;
|
|
if (o.kind === 'attackBoost') return `Let their unit gain ${o.n || 1} attack`;
|
|
return o.kind;
|
|
};
|
|
d.options.forEach((o, i) => {
|
|
this.modalButton(cx, cy + i * 60, label(o), () => this.applyDecision(d, i), {
|
|
width: 420, variant: i === 0 ? undefined : 'ghost',
|
|
});
|
|
});
|
|
}
|
|
|
|
// Y-Wing: pick the enemy base or one of their capital ships to take the hit.
|
|
showDealDamageModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 300, 'CHOOSE A TARGET', {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: C.badHex,
|
|
}).setOrigin(0.5));
|
|
const o = this.gs.players[1 - this.humanSeat];
|
|
const items = d.candidates.map((ref) => {
|
|
if (ref.kind === 'oppBase') return { ref, base: true };
|
|
const e = o.capitals.find((x) => x.card.uid === ref.uid);
|
|
return e ? { ref, inst: e.card, damage: e.damage } : null;
|
|
}).filter(Boolean);
|
|
const pitch = Math.min(260, 1400 / Math.max(1, items.length));
|
|
const x0 = cx - ((items.length - 1) * pitch) / 2;
|
|
items.forEach((it, i) => {
|
|
const x = x0 + i * pitch;
|
|
if (it.base) {
|
|
this.makeBaseCard(x, 540, o.faction, o.base, 240, 152, {
|
|
parent: root, hover: false, showText: false, highlight: true,
|
|
onClick: () => this.applyDecision(d, it.ref),
|
|
});
|
|
root.add(this.add.text(x, 650, 'enemy base', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
} else {
|
|
this.makeCard(x, 540, it.inst, 190, 264, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, it.ref),
|
|
});
|
|
root.add(this.add.text(x, 690, `${it.damage} damage taken`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Jawa Scavenger: purchase (pay cost) from the galaxy discard pile.
|
|
showGalaxyDiscardModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 240, 'PURCHASE FROM THE GALAXY DISCARD PILE', {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
const cards = d.candidates
|
|
.map((ref) => ({ ref, inst: this.gs.galaxy.discard.find((c) => c.uid === ref.uid) }))
|
|
.filter((c) => c.inst);
|
|
const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6))));
|
|
const cw = 150, chh = 208;
|
|
cards.forEach((c, i) => {
|
|
const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12);
|
|
const y = 430 + Math.floor(i / cols) * (chh + 14);
|
|
this.makeCard(x, y, c.inst, cw, chh, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, c.ref),
|
|
});
|
|
});
|
|
if (d.optional) this.modalButton(cx, GAME_HEIGHT - 110, 'Skip', () => this.applyDecision(d, null), { variant: 'ghost', width: 180 });
|
|
}
|
|
|
|
// Moff Jerjerrod: peek the galaxy deck's top card; with the Force, may swap
|
|
// it with a row card (the row card goes on top of the deck).
|
|
showLookTopSwapModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
const rowRefs = d.candidates.filter((c) => c.kind === 'row');
|
|
root.add(this.add.text(cx, 210, 'TOP OF THE GALAXY DECK', {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
if (d.peekId) this.makeCard(cx, 400, { uid: -1, id: d.peekId }, 200, 278, { parent: root, hover: false, showText: true, noHoverPreview: true });
|
|
if (rowRefs.length) {
|
|
root.add(this.add.text(cx, 580, 'The Force is with you — swap it with a galaxy-row card?', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text,
|
|
}).setOrigin(0.5));
|
|
const cards = rowRefs
|
|
.map((ref) => ({ ref, inst: this.gs.galaxy.row.find((c) => c.uid === ref.uid) }))
|
|
.filter((c) => c.inst);
|
|
const pitch = Math.min(180, 1400 / Math.max(1, cards.length));
|
|
const x0 = cx - ((cards.length - 1) * pitch) / 2;
|
|
cards.forEach((c, i) => {
|
|
this.makeCard(x0 + i * pitch, 750, c.inst, 150, 208, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, c.ref),
|
|
});
|
|
});
|
|
}
|
|
this.modalButton(cx, GAME_HEIGHT - 90, 'Leave it on top', () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 300, variant: rowRefs.length ? 'ghost' : undefined });
|
|
}
|
|
|
|
// Jyn Erso: the opponent's hand is revealed — pick one card to place on top
|
|
// of their deck.
|
|
showOppTopdeckModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 300, "OPPONENT'S HAND — PICK ONE TO TOPDECK", {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(cx, 345, 'The chosen card goes on top of their deck', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
const pitch = Math.min(200, 1400 / Math.max(1, d.candidates.length));
|
|
const x0 = cx - ((d.candidates.length - 1) * pitch) / 2;
|
|
d.candidates.forEach((ref, i) => {
|
|
this.makeCard(x0 + i * pitch, 560, { uid: ref.uid, id: ref.id }, 170, 236, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, ref),
|
|
});
|
|
});
|
|
}
|
|
|
|
showOptionModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
|
root.add(this.add.text(cx, cy - 110, 'CHOOSE ONE', {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
d.options.forEach((ab, i) => {
|
|
this.modalButton(cx, cy - 30 + i * 60, this.abilityLabel(ab), () => this.applyDecision(d, i), { width: 480 });
|
|
});
|
|
}
|
|
|
|
abilityLabel(ab) {
|
|
switch (ab.op) {
|
|
case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`;
|
|
case 'oppDiscard': return 'Opponent discards a card';
|
|
case 'gainResources': return `Gain ${ab.n} resource${(ab.n || 1) > 1 ? 's' : ''}`;
|
|
case 'gainForce': return `Gain ${ab.n} Force`;
|
|
case 'gainAttack': return `Gain ${ab.n} attack this turn`;
|
|
case 'damageBase': return `Deal ${ab.n} damage to the enemy base`;
|
|
case 'repairBase': return `Repair ${ab.n} damage from your base`;
|
|
default: return ab.op;
|
|
}
|
|
}
|
|
|
|
showPeekModal(d, title, keepLabel, discardLabel) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
|
root.add(this.add.text(cx, cy - 230, title, {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
if (d.peekId) this.makeCard(cx, cy - 20, { uid: -1, id: d.peekId }, 220, 306, { parent: root, hover: false, showText: true, noHoverPreview: true });
|
|
this.modalButton(cx - 150, cy + 200, keepLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 260, variant: 'ghost' });
|
|
this.modalButton(cx + 150, cy + 200, discardLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'discard')), { width: 260 });
|
|
}
|
|
|
|
showCapitalTargetModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 300, 'DESTROY A CAPITAL SHIP', {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: C.badHex,
|
|
}).setOrigin(0.5));
|
|
const o = this.gs.players[1 - this.humanSeat];
|
|
const cards = d.candidates.map((ref) => {
|
|
if (ref.kind === 'capital') {
|
|
const e = o.capitals.find((x) => x.card.uid === ref.uid);
|
|
return { ref, inst: e.card, note: 'in play' };
|
|
}
|
|
const inst = this.gs.galaxy.row.find((x) => x.uid === ref.uid);
|
|
return { ref, inst, note: 'galaxy row' };
|
|
});
|
|
const pitch = Math.min(240, 1400 / Math.max(1, cards.length));
|
|
const x0 = cx - ((cards.length - 1) * pitch) / 2;
|
|
cards.forEach((c, i) => {
|
|
this.makeCard(x0 + i * pitch, 540, c.inst, 190, 264, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, c.ref),
|
|
});
|
|
root.add(this.add.text(x0 + i * pitch, 690, c.note, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
});
|
|
}
|
|
|
|
showRecoverModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 280, 'RETURN A CARD TO YOUR HAND', {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
const p = this.gs.players[this.humanSeat];
|
|
const cards = d.candidates.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst);
|
|
const pitch = Math.min(220, 1400 / Math.max(1, cards.length));
|
|
const x0 = cx - ((cards.length - 1) * pitch) / 2;
|
|
cards.forEach((c, i) => {
|
|
this.makeCard(x0 + i * pitch, 540, c.inst, 180, 250, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, c.ref),
|
|
});
|
|
});
|
|
}
|
|
|
|
showDiscardPickModal(d) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 240, 'EXILE FROM YOUR DISCARD PILE', {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
const p = this.gs.players[this.humanSeat];
|
|
const cands = (d.candidates || []).filter((c) => c.zone === 'discard');
|
|
const cards = cands.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst);
|
|
const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6))));
|
|
const cw = 150, chh = 208;
|
|
cards.forEach((c, i) => {
|
|
const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12);
|
|
const y = 430 + Math.floor(i / cols) * (chh + 14);
|
|
this.makeCard(x, y, c.inst, cw, chh, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => this.applyDecision(d, c.ref),
|
|
});
|
|
});
|
|
this.modalButton(cx, GAME_HEIGHT - 110, 'Back', () => { this.closeModal(); }, { variant: 'ghost', width: 180 });
|
|
}
|
|
|
|
showHandPickModal(title, onPick) {
|
|
const root = this.modalRoot();
|
|
const cx = GAME_WIDTH / 2;
|
|
root.add(this.add.text(cx, 300, title.toUpperCase(), {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
|
|
}).setOrigin(0.5));
|
|
const p = this.gs.players[this.humanSeat];
|
|
const pitch = Math.min(200, 1400 / Math.max(1, p.hand.length));
|
|
const x0 = cx - ((p.hand.length - 1) * pitch) / 2;
|
|
p.hand.forEach((inst, i) => {
|
|
this.makeCard(x0 + i * pitch, 560, inst, 170, 236, {
|
|
parent: root, hover: false, showText: true,
|
|
onClick: () => { this.closeModal(); onPick(inst.uid); },
|
|
});
|
|
});
|
|
this.modalButton(cx, 760, 'Cancel', () => { this.closeModal(); }, { variant: 'ghost', width: 180 });
|
|
}
|
|
|
|
showInspect(inst) {
|
|
if (this._modal || !inst || inst.hidden) return;
|
|
const root = this.modalRoot();
|
|
this.makeCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, inst, 340, 470, { parent: root, hover: false, showText: true, noHoverPreview: true });
|
|
const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive();
|
|
zone.on('pointerdown', () => this.closeModal());
|
|
root.add(zone);
|
|
root.bringToTop(zone);
|
|
}
|
|
|
|
showInspectBase(faction, base) {
|
|
if (this._modal) return;
|
|
const root = this.modalRoot();
|
|
this.makeBaseCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, faction, base, 560, 340, { parent: root, hover: false, showText: true, noHoverPreview: true });
|
|
const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive();
|
|
zone.on('pointerdown', () => this.closeModal());
|
|
root.add(zone);
|
|
root.bringToTop(zone);
|
|
}
|
|
|
|
// ── prompt + action buttons ─────────────────────────────────────────────────
|
|
setPrompt(txt) { this.promptText.setText(txt || ''); }
|
|
addActionButton(label, cb) {
|
|
const i = this._actionButtons.length;
|
|
const b = new Button(this, 1700, 700 + i * 76, label, cb, { width: 190, fontSize: 18 });
|
|
b.setDepth(DEPTH.ui);
|
|
this._actionButtons.push(b);
|
|
this.guardFreshButton(b);
|
|
}
|
|
clearActionButtons() {
|
|
for (const b of this._actionButtons) b.destroy();
|
|
this._actionButtons = [];
|
|
}
|
|
|
|
// ── event playback ──────────────────────────────────────────────────────────
|
|
playEvents(events, opts = {}) {
|
|
this.busy = true;
|
|
this.setPrompt('');
|
|
// a purchase or a bounty kill always emits its rowRefill just before its
|
|
// own buy/bounty event (SWDBGLogic's removeRowCard refills before
|
|
// buyCard/attackRow emit) — swap them so the bought/defeated card's full
|
|
// animation plays out (arc + reveal/explosion) before the replacement
|
|
// card animates into the vacated row slot
|
|
events = events.slice();
|
|
for (let i = 1; i < events.length; i++) {
|
|
if ((events[i].type === 'buy' || events[i].type === 'bounty') && events[i - 1].type === 'rowRefill') {
|
|
[events[i - 1], events[i]] = [events[i], events[i - 1]];
|
|
}
|
|
}
|
|
// an end-of-turn discard batch has no stored position for the old hand's
|
|
// cards (by the time these events are read, endTurn() has already drawn
|
|
// the NEW hand) — reconstruct the human's virtual old-hand layout purely
|
|
// from the emitted events' own order (one 'endTurnDiscard' zone:'hand'
|
|
// event per old card, in original hand order)
|
|
const handDiscardsBySeat = new Map();
|
|
for (const e of events) {
|
|
if (e.type === 'endTurnDiscard' && e.zone === 'hand') {
|
|
if (!handDiscardsBySeat.has(e.seat)) handDiscardsBySeat.set(e.seat, []);
|
|
handDiscardsBySeat.get(e.seat).push(e.uid);
|
|
}
|
|
}
|
|
for (const uids of handDiscardsBySeat.values()) {
|
|
uids.forEach((uid, idx) => this._endTurnHandLayout.set(uid, { idx, total: uids.length }));
|
|
}
|
|
for (const e of events) {
|
|
if ((e.type === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid);
|
|
if (e.type === 'buy' && e.uid != null) {
|
|
if (this._rowPos.has(e.uid)) this._rowGhosts.set(e.uid, e.id);
|
|
const dest = e.dest || (e.topdeck ? 'deck' : 'discard');
|
|
if (dest === 'deck') this._pendingDeckUids.add(e.uid);
|
|
else if (dest === 'hand') this._pendingCardUids.add(e.uid);
|
|
else this._pendingDiscardUids.add(e.uid);
|
|
}
|
|
if (e.type === 'bounty' && e.uid != null && this._rowPos.has(e.uid)) {
|
|
this._rowGhosts.set(e.uid, e.id);
|
|
}
|
|
if (e.type === 'endTurnDiscard' && e.uid != null) {
|
|
this._pendingDiscardUids.add(e.uid);
|
|
this._endTurnGhosts.set(e.uid, { seat: e.seat, id: e.id, zone: e.zone });
|
|
if (e.zone === 'inPlay' && this._playPos.has(e.uid)) this._endTurnFromPos.set(e.uid, this._playPos.get(e.uid));
|
|
}
|
|
}
|
|
const queue = events.filter((e) => this.eventDelay(e) > 0);
|
|
const step = () => {
|
|
const e = queue.shift();
|
|
if (!e) {
|
|
this.busy = false;
|
|
this.renderAll();
|
|
this.pump();
|
|
opts.onDone?.();
|
|
return;
|
|
}
|
|
this.eventFx(e);
|
|
this.time.delayedCall(this.eventDelay(e), step);
|
|
};
|
|
this.renderAll();
|
|
step();
|
|
}
|
|
|
|
eventDelay(e) {
|
|
const aiEvent = e.seat != null && e.seat !== this.humanSeat;
|
|
switch (e.type) {
|
|
case 'turnStart': return 460;
|
|
case 'play': return aiEvent ? 300 : 220;
|
|
case 'buy': return 2300; // 500 funding arc + 420 reveal + 1000 hold + 380 flip-to-discard
|
|
case 'bounty': return 1140; // 500 attack arc + 640 existing popText/banner hold
|
|
case 'attackBase': return 600; // 500 attack arc + 100 buffer before capitalDamage/baseDamage resolve
|
|
case 'capitalDamage': return 300;
|
|
case 'capitalDestroyed': return 520;
|
|
case 'baseDamage': return 460;
|
|
case 'baseDestroyed': return 980;
|
|
case 'baseChosen': return 520;
|
|
case 'forceMove': return 240;
|
|
case 'forceBonus': return 240;
|
|
case 'reveal': return 700;
|
|
case 'exile': return 260;
|
|
case 'discard': return (e.forced || e.cost) ? 340 : 0;
|
|
case 'recover': return 300;
|
|
case 'repair': return 300;
|
|
case 'rowDiscard': return 340;
|
|
case 'handReveal': return e.bySeat === this.humanSeat ? 2000 : 700;
|
|
case 'oppTopdeck': return 500;
|
|
case 'rowSwap': return 700;
|
|
case 'takeRow': return 500;
|
|
case 'topdeckArmed': return 400;
|
|
case 'attackBoost': return 260;
|
|
case 'galaxyReshuffle': return 300;
|
|
case 'draw': return this._dealingInitial ? 300 : 260;
|
|
case 'rowRefill': return this._dealingInitial ? 320 : 280;
|
|
case 'endTurnDiscard': return 200;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
// fly + resize + optional mid-flight flip, used for dealing a card from a
|
|
// pile (draw pile / galaxy deck / center-screen reveal) to its final slot.
|
|
// startAs picks the initial visual ('back' or 'face'); flipTo, if set,
|
|
// swaps it mid-flight to 'face' or 'back' — covering reveals (back->face)
|
|
// and cards going into a hidden pile (face->back). The flip runs inside a
|
|
// nested container so its scaleX collapse doesn't fight the outer tween's
|
|
// own position/resize scale, and is timed to finish with margin before the
|
|
// outer tween's onComplete destroys everything.
|
|
dealCardAnimated(opts) {
|
|
const {
|
|
fromX, fromY, fromW, fromH,
|
|
toX, toY, toW, toH,
|
|
toAngle = 0,
|
|
startAs = 'back', // 'back' | 'face' — visual at the start of the flight
|
|
flipTo = null, // 'face' | 'back' | null — what it flips into mid-flight, if anything
|
|
id = null, // card def id, needed whenever a face is shown (start and/or flip target)
|
|
duration = 280,
|
|
keepAlive = false, // if true, don't destroy the token on arrival — caller owns its lifecycle via onLand(token)
|
|
onLand,
|
|
} = opts;
|
|
const outer = this.add.container(fromX, fromY);
|
|
outer.setScale(fromW / toW, fromH / toH);
|
|
this.fxLayer.add(outer);
|
|
const flipHost = this.add.container(0, 0);
|
|
outer.add(flipHost);
|
|
const buildSide = (side) => side === 'face'
|
|
? this.makeCard(0, 0, { uid: -1, id }, toW, toH, { parent: flipHost, hover: false, showText: true, noHoverPreview: true })
|
|
: this.makeCardBack(0, 0, toW, toH, flipHost);
|
|
let side = buildSide(startAs);
|
|
|
|
this.tweens.add({
|
|
targets: outer, x: toX, y: toY, scaleX: 1, scaleY: 1, angle: toAngle,
|
|
duration, ease: 'Cubic.easeInOut',
|
|
onComplete: () => { if (!keepAlive) outer.destroy(); onLand?.(outer); },
|
|
});
|
|
|
|
if (flipTo != null) {
|
|
this.tweens.add({
|
|
targets: flipHost, scaleX: 0, duration: duration * 0.35, delay: duration * 0.15,
|
|
ease: 'Cubic.easeIn',
|
|
onComplete: () => {
|
|
side.destroy();
|
|
side = buildSide(flipTo);
|
|
this.tweens.add({ targets: flipHost, scaleX: 1, duration: duration * 0.35, ease: 'Cubic.easeOut' });
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
animateDraw(e) {
|
|
const p = this.gs.players[e.seat];
|
|
const idx = p.hand.findIndex((c) => c.uid === e.uid);
|
|
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
|
const dur = this._dealingInitial ? 300 : 260;
|
|
if (e.seat === this.humanSeat) {
|
|
this.dealCardAnimated({
|
|
fromX: 392, fromY: 911, fromW: 90, fromH: 124,
|
|
toX: this.humanHandSlotX(idx, p.hand.length), toY: 985, toW: 148, toH: 206,
|
|
startAs: 'back', flipTo: 'face', id: e.id, duration: dur,
|
|
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
} else {
|
|
const remainingPending = p.hand.filter((c) => this._pendingCardUids.has(c.uid) && c.uid !== e.uid).length;
|
|
const visibleAfter = p.hand.length - remainingPending;
|
|
const shown = Math.min(visibleAfter, 4);
|
|
const off = (shown - 1) / 2; // land in the rightmost fan slot — backs are interchangeable
|
|
this.dealCardAnimated({
|
|
fromX: 570, fromY: 132, fromW: 30, fromH: 42,
|
|
toX: 570 + off * 7, toY: 72, toW: 30, toH: 42, toAngle: off * 7,
|
|
startAs: 'back', flipTo: null, duration: dur,
|
|
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
}
|
|
}
|
|
|
|
// end-of-turn cleanup: every discarded in-play unit and every discarded
|
|
// hand card stays visible in its original spot (via _endTurnGhosts) right
|
|
// up until this exact moment, then flies individually to the owner's
|
|
// discard pile. In-play cards fly from their pre-mutation board position
|
|
// (_endTurnFromPos, snapshotted in playEvents); hand cards fly from a
|
|
// virtual old-hand layout reconstructed purely from event order
|
|
// (_endTurnHandLayout), since the real hand has already been replaced with
|
|
// the new one by the time this event is processed. Own cards flip
|
|
// face-down (the discard pile is shown as a back); the opponent's hand
|
|
// cards were already hidden, so no flip.
|
|
animateEndTurnDiscard(e) {
|
|
const isHuman = e.seat === this.humanSeat;
|
|
const dest = isHuman
|
|
? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 }
|
|
: { x: 570, y: 192, w: 30, h: 42 };
|
|
let fromX, fromY, fromW, fromH, startAs, flipTo;
|
|
if (e.zone === 'inPlay') {
|
|
const pos = this._endTurnFromPos.get(e.uid) || dest;
|
|
this._endTurnFromPos.delete(e.uid);
|
|
fromX = pos.x; fromY = pos.y;
|
|
fromW = isHuman ? 122 : 84; fromH = isHuman ? 170 : 118;
|
|
startAs = 'face'; flipTo = 'back';
|
|
} else if (isHuman) {
|
|
const layout = this._endTurnHandLayout.get(e.uid) || { idx: 0, total: 1 };
|
|
this._endTurnHandLayout.delete(e.uid);
|
|
fromX = this.humanHandSlotX(layout.idx, layout.total); fromY = 985;
|
|
fromW = 148; fromH = 206;
|
|
startAs = 'face'; flipTo = 'back';
|
|
} else {
|
|
this._endTurnHandLayout.delete(e.uid);
|
|
fromX = 570; fromY = 72;
|
|
fromW = 30; fromH = 42;
|
|
startAs = 'back'; flipTo = null;
|
|
}
|
|
this._endTurnGhosts.delete(e.uid);
|
|
this.renderAll();
|
|
this.dealCardAnimated({
|
|
fromX, fromY, fromW, fromH,
|
|
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
|
|
startAs, flipTo, id: e.id, duration: 200,
|
|
onLand: () => { this._pendingDiscardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
}
|
|
|
|
// opponent's hand is only ever shown as a generic face-down fan, so a played
|
|
// card animates out of that fan (reveal-flip included, since playing it is
|
|
// exactly the moment it stops being secret) into its capital/in-play slot
|
|
animatePlay(e) {
|
|
const p = this.gs.players[e.seat];
|
|
const pool = e.capital ? p.capitals : p.inPlay;
|
|
const idx = pool.findIndex((entry) => entry.card.uid === e.uid);
|
|
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
|
const slot = e.capital ? this.oppCapitalSlot(idx) : this.oppInPlaySlot(idx);
|
|
this.dealCardAnimated({
|
|
fromX: 570, fromY: 72, fromW: 30, fromH: 42,
|
|
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
|
startAs: 'back', flipTo: 'face', id: e.id, duration: 300,
|
|
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
}
|
|
|
|
// the human already knows their own card (they just clicked it), so it
|
|
// flies already face-up — no back stage, no flip — from the hand strip
|
|
// into its capital/in-play slot
|
|
animateHumanPlay(e) {
|
|
const p = this.gs.players[e.seat];
|
|
const pool = e.capital ? p.capitals : p.inPlay;
|
|
const idx = pool.findIndex((entry) => entry.card.uid === e.uid);
|
|
if (idx < 0) { this._pendingCardUids.delete(e.uid); return; }
|
|
const slot = e.capital ? this.humanCapitalSlot(idx) : this.humanInPlaySlot(idx, pool.length);
|
|
this.dealCardAnimated({
|
|
fromX: GAME_WIDTH / 2, fromY: 985, fromW: 60, fromH: 84,
|
|
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
|
startAs: 'face', flipTo: null, id: e.id, duration: 220,
|
|
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
}
|
|
|
|
animateRowRefill(e) {
|
|
// the stable slot map (assigned in renderGalaxy()) already places this
|
|
// card in whatever slot its predecessor vacated, so it always lands
|
|
// exactly where the purchased/lost card used to sit
|
|
const idx = this._rowSlots.get(e.uid);
|
|
if (idx == null) { this._pendingCardUids.delete(e.uid); return; }
|
|
const humanFaction = this.gs.players[this.humanSeat].faction;
|
|
const slot = this.galaxyRowSlot(idx, cardDef(e.id).faction, humanFaction);
|
|
this.dealCardAnimated({
|
|
fromX: 330, fromY: 430, fromW: 158 * 0.9, fromH: 222 * 0.9,
|
|
toX: slot.x, toY: slot.y, toW: slot.w, toH: slot.h,
|
|
startAs: 'back', flipTo: 'face', id: e.id, duration: this._dealingInitial ? 320 : 280,
|
|
onLand: () => { this._pendingCardUids.delete(e.uid); this.renderAll(); },
|
|
});
|
|
}
|
|
|
|
// buying a card is a four-beat moment: funding arcs converge on it while it
|
|
// still sits in its row slot (rendered via _rowGhosts), then it flies up to
|
|
// a big center-screen reveal (already face-up, no flip) — leaving its row
|
|
// slot empty from that instant on — holds there so it can be read, then
|
|
// flips face-down and shrinks into the buyer's discard pile (or, for the
|
|
// rare "topdeck it" ability, the draw pile instead). The row's own
|
|
// replacement card (a separate 'rowRefill' event, reordered to play right
|
|
// after this one in playEvents) lands in that same now-empty slot only
|
|
// once this whole sequence completes.
|
|
animateBuy(e) {
|
|
const src = this._rowPos.get(e.uid) || this._orpPos || { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
|
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
|
const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size
|
|
const destKind = e.dest || (e.topdeck ? 'deck' : 'discard');
|
|
let dest, stowFlip = 'back';
|
|
if (destKind === 'deck') {
|
|
dest = e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 };
|
|
} else if (destKind === 'hand') {
|
|
// Fang Fighter — the card joins its buyer's hand (face-up for the human,
|
|
// into the face-down fan for the AI)
|
|
dest = e.seat === this.humanSeat ? { x: GAME_WIDTH / 2, y: 985, w: 148, h: 206 } : { x: 570, y: 72, w: 30, h: 42 };
|
|
if (e.seat === this.humanSeat) stowFlip = null;
|
|
} else {
|
|
dest = e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 };
|
|
}
|
|
const land = () => {
|
|
this._pendingCardUids.delete(e.uid);
|
|
this._pendingDiscardUids.delete(e.uid);
|
|
this._pendingDeckUids.delete(e.uid);
|
|
this.renderAll();
|
|
};
|
|
const startReveal = () => {
|
|
// the card leaves its row slot the instant it starts flying to the
|
|
// center-screen reveal — clearing the ghost here (rather than at the
|
|
// very end) is what frees the slot up for the eventual rowRefill card
|
|
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
|
|
this.dealCardAnimated({
|
|
fromX: src.x, fromY: src.y, fromW: 158, fromH: 222,
|
|
toX: cx, toY: cy, toW: bigW, toH: bigH,
|
|
startAs: 'face', flipTo: null, id: e.id, duration: 420,
|
|
keepAlive: true,
|
|
onLand: (token) => {
|
|
this.time.delayedCall(1000, () => {
|
|
token.destroy();
|
|
this.dealCardAnimated({
|
|
fromX: cx, fromY: cy, fromW: bigW, fromH: bigH,
|
|
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h,
|
|
startAs: 'face', flipTo: stowFlip, id: e.id, duration: 380,
|
|
onLand: land,
|
|
});
|
|
});
|
|
},
|
|
});
|
|
};
|
|
// Funding arcs: gold arcs converge from every one of the buyer's currently
|
|
// in-play cards (units + capitals — unaffected by the purchase itself) onto
|
|
// the row/outer-rim slot being bought, before the reveal-hold-stow sequence
|
|
// starts. _playPos is read live since the buyer's board is untouched by a
|
|
// purchase; _rowPos for the bought card itself is kept fresh by the
|
|
// _rowGhosts render in renderGalaxy() until startReveal() clears it.
|
|
const buyer = this.gs.players[e.seat];
|
|
const sources = [...buyer.inPlay, ...buyer.capitals]
|
|
.map((entry) => this._playPos.get(entry.card.uid))
|
|
.filter(Boolean);
|
|
if (!sources.length) {
|
|
const basePos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
|
|
if (basePos) sources.push(basePos);
|
|
}
|
|
this.drawArcArrows(sources, src.x, src.y, C.gold, {}, startReveal);
|
|
}
|
|
|
|
eventFx(e) {
|
|
switch (e.type) {
|
|
case 'turnStart':
|
|
if (e.seat !== this.humanSeat) this.showBanner(`${this.seatName(e.seat)}'s turn`.toUpperCase());
|
|
else this.showBanner('YOUR TURN');
|
|
this.sfx(SFX.CARD_DEAL);
|
|
break;
|
|
case 'play':
|
|
this.sfx(SFX.CARD_PLACE);
|
|
if (e.seat !== this.humanSeat) this.animatePlay(e);
|
|
else this.animateHumanPlay(e);
|
|
break;
|
|
case 'buy':
|
|
this.sfx(SFX.PURCHASE);
|
|
this.animateBuy(e);
|
|
break;
|
|
case 'bounty': {
|
|
// the target stays visible in its row slot (via _rowGhosts, held
|
|
// open the same way a bought card is) until the arrow actually
|
|
// reaches it
|
|
const at = this._rowPos.get(e.uid);
|
|
const from = this._pendingAttackFrom;
|
|
this._pendingAttackFrom = [];
|
|
const impact = () => {
|
|
if (this._rowGhosts.get(e.uid) === e.id) { this._rowGhosts.delete(e.uid); this.renderAll(); }
|
|
this.sfx(SFX.SCIFI_EXPLODE);
|
|
if (at) {
|
|
this.popText(at.x, at.y, '💥', '#ffffff', 44);
|
|
this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20);
|
|
}
|
|
};
|
|
if (at && from.length) this.drawArcArrows(from, at.x, at.y, C.bad, {}, impact);
|
|
else impact();
|
|
break;
|
|
}
|
|
case 'attackBase': {
|
|
this.sfx(SFX.SCIFI_LAUNCH);
|
|
const dest = e.seat === this.humanSeat ? this._oppBasePos : this._myBasePos;
|
|
const from = this._pendingAttackFrom;
|
|
this._pendingAttackFrom = [];
|
|
if (dest && from.length) this.drawArcArrows(from, dest.x, dest.y, C.bad, {});
|
|
break;
|
|
}
|
|
case 'capitalDamage': {
|
|
const at = this._playPos.get(e.uid);
|
|
if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 36); this.shakeAt(at.x, at.y); }
|
|
break;
|
|
}
|
|
case 'capitalDestroyed': {
|
|
this.sfx(SFX.SCIFI_EXPLODE);
|
|
const at = this._playPos.get(e.uid) || this._rowPos.get(e.uid);
|
|
if (at) this.popText(at.x, at.y, '💥', '#ffffff', 40);
|
|
this.popText(GAME_WIDTH / 2, 320, `${cardDef(e.id).name} destroyed`, '#ff6b5e', 22);
|
|
break;
|
|
}
|
|
case 'baseDamage': {
|
|
const pos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos;
|
|
if (pos) {
|
|
this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 44);
|
|
const flash = this.add.rectangle(pos.x, pos.y, 310, 200, 0xd6604d, 0.35).setDepth(DEPTH.fx);
|
|
this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() });
|
|
this.sfx(SFX.BATTLESHIP_HIT);
|
|
}
|
|
break;
|
|
}
|
|
case 'baseDestroyed': {
|
|
this.sfx(SFX.SCIFI_EXPLODE);
|
|
const name = baseDef(this.gs.players[e.seat].faction, e.id).name;
|
|
this.showBanner(e.seat === this.humanSeat
|
|
? `💥 ${name} HAS FALLEN (${e.lost}/${this.gs.meta.basesToWin})`
|
|
: `💥 YOU DESTROYED ${name.toUpperCase()} (${e.lost}/${this.gs.meta.basesToWin})`);
|
|
this.cameras.main.shake(240, 0.006);
|
|
break;
|
|
}
|
|
case 'baseChosen':
|
|
this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase());
|
|
break;
|
|
case 'forceMove': {
|
|
playForceMove(this, e.to > e.from);
|
|
if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y);
|
|
break;
|
|
}
|
|
case 'forceBonus':
|
|
this.popText(GAME_WIDTH / 2, 560, 'The Force provides: +1 ▣', '#9fd8f0', 20);
|
|
break;
|
|
case 'reveal': {
|
|
const card = this.makeCard(GAME_WIDTH / 2, 430, { uid: -1, id: e.id }, 180, 250, { parent: this.fxLayer, hover: false, showText: true, noHoverPreview: true });
|
|
card.setScale(0.4).setAlpha(0);
|
|
this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' });
|
|
this.time.delayedCall(620, () => this.fxLayer.removeAll(true));
|
|
break;
|
|
}
|
|
case 'exile':
|
|
this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} exiled`, '#b06bd8', 18);
|
|
break;
|
|
case 'discard':
|
|
if (e.forced || e.cost) this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, `${this.seatName(e.seat)} discard${e.seat === this.humanSeat ? '' : 's'} ${cardDef(e.id).name}`, '#c7cede', 18);
|
|
break;
|
|
case 'handReveal': {
|
|
if (e.bySeat === this.humanSeat) {
|
|
// show the opponent's revealed hand as a face-up fan for a beat
|
|
const n = e.ids.length;
|
|
const pitch = Math.min(190, 1300 / Math.max(1, n));
|
|
const x0 = GAME_WIDTH / 2 - ((n - 1) * pitch) / 2;
|
|
e.ids.forEach((id, i) => {
|
|
const card = this.makeCard(x0 + i * pitch, 430, { uid: -1, id }, 170, 236, { parent: this.fxLayer, hover: false, showText: true, noHoverPreview: true });
|
|
card.setAlpha(0);
|
|
this.tweens.add({ targets: card, alpha: 1, duration: 160, delay: i * 60 });
|
|
});
|
|
this.popText(GAME_WIDTH / 2, 260, "Opponent's hand revealed", C.goldHex, 22);
|
|
this.time.delayedCall(1900, () => this.fxLayer.removeAll(true));
|
|
} else {
|
|
this.showBanner('THEY SEE YOUR HAND');
|
|
}
|
|
break;
|
|
}
|
|
case 'oppTopdeck':
|
|
this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200,
|
|
`${cardDef(e.id).name} placed on top of ${this.seatName(e.seat)}'s deck`, '#b06bd8', 18);
|
|
break;
|
|
case 'rowSwap': {
|
|
this.sfx(SFX.CARD_PLACE);
|
|
const at = this._rowPos.get(e.inUid);
|
|
if (at) this.shakeAt(at.x, at.y);
|
|
this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.inId).name} swapped into the row (${cardDef(e.outId).name} back on top)`, '#c7cede', 18);
|
|
break;
|
|
}
|
|
case 'takeRow':
|
|
this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.id).name} taken in hand — exiles at end of turn`, '#b06bd8', 18);
|
|
break;
|
|
case 'topdeckArmed':
|
|
this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, 'Next purchase goes on top of the deck', C.goldHex, 18);
|
|
break;
|
|
case 'attackBoost': {
|
|
const at = this._playPos.get(e.uid);
|
|
if (at) this.popText(at.x, at.y - 20, `+${e.n} ⚔`, C.statAtkHex, 30);
|
|
break;
|
|
}
|
|
case 'recover':
|
|
this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18);
|
|
break;
|
|
case 'repair':
|
|
if (this._myBasePos && e.seat === this.humanSeat) this.popText(this._myBasePos.x, this._myBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24);
|
|
else if (this._oppBasePos) this.popText(this._oppBasePos.x, this._oppBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24);
|
|
break;
|
|
case 'rowDiscard':
|
|
this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.id).name} swept from the row`, '#c7cede', 18);
|
|
break;
|
|
case 'galaxyReshuffle':
|
|
this.sfx(SFX.CARD_SHUFFLE);
|
|
break;
|
|
case 'draw':
|
|
this.sfx(SFX.CARD_DEAL);
|
|
this.animateDraw(e);
|
|
break;
|
|
case 'rowRefill':
|
|
this.sfx(SFX.CARD_DEAL);
|
|
this.animateRowRefill(e);
|
|
break;
|
|
case 'endTurnDiscard':
|
|
this.sfx(SFX.CARD_PLACE);
|
|
this.animateEndTurnDiscard(e);
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
popText(x, y, txt, color, size = 20) {
|
|
const t = this.add.text(x, y, txt, {
|
|
fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#05070f', strokeThickness: 4,
|
|
}).setOrigin(0.5).setDepth(DEPTH.fx);
|
|
this.tweens.add({ targets: t, y: y - 44, alpha: 0, duration: 950, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
|
|
}
|
|
|
|
shakeAt(x, y) {
|
|
const g = this.add.graphics().setDepth(DEPTH.fx);
|
|
g.lineStyle(3, 0xffffff, 0.5);
|
|
g.strokeCircle(x, y, 24);
|
|
this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() });
|
|
}
|
|
|
|
// Risk-style converging attack arc: manual quadratic-bezier re-rasterized every
|
|
// tween tick (not Phaser's Curves.QuadraticBezier), with a white-outline pass
|
|
// under the colored pass and an arrowhead aligned to the curve's tangent at the
|
|
// tip. Callback-style (onDone) to match this file's other animation helpers.
|
|
drawArcArrow(fromX, fromY, toX, toY, color, opts = {}) {
|
|
const { duration = 500, archCap = 90, linger = 120, onDone } = opts;
|
|
const dx = toX - fromX, dy = toY - fromY;
|
|
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, archCap);
|
|
const cpx = (fromX + toX) / 2 + perpX * archH;
|
|
const cpy = (fromY + toY) / 2 + perpY * archH;
|
|
|
|
const g = this.add.graphics().setDepth(DEPTH.fx);
|
|
const LINE_W = 5, STROKE_W = 2, ARROW_LEN = 20, 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) * fromX + 2 * (1 - tt) * tt * cpx + tt * tt * toX,
|
|
(1 - tt) * (1 - tt) * fromY + 2 * (1 - tt) * tt * cpy + tt * tt * toY,
|
|
);
|
|
}
|
|
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) * fromX + 2 * (1 - t) * t * cpx + t * t * toX;
|
|
const ty = (1 - t) * (1 - t) * fromY + 2 * (1 - t) * t * cpy + t * t * toY;
|
|
const px = (1 - prevT) * (1 - prevT) * fromX + 2 * (1 - prevT) * prevT * cpx + prevT * prevT * toX;
|
|
const py = (1 - prevT) * (1 - prevT) * fromY + 2 * (1 - prevT) * prevT * cpy + prevT * prevT * toY;
|
|
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,
|
|
);
|
|
}
|
|
};
|
|
|
|
const progress = { t: 0 };
|
|
this.tweens.add({
|
|
targets: progress, t: 1, duration, ease: 'Sine.easeInOut',
|
|
onUpdate: () => drawArc(progress.t),
|
|
onComplete: () => {
|
|
drawArc(1);
|
|
onDone?.();
|
|
this.time.delayedCall(linger, () => g.destroy());
|
|
},
|
|
});
|
|
}
|
|
|
|
// Fan-in wrapper: fires one drawArcArrow per source point simultaneously,
|
|
// calling onAllDone once every arc has individually finished. Used both for
|
|
// the buy funding arc (many sources converge on one purchase) and the attack
|
|
// arc (many attacking squad members converge on one target).
|
|
drawArcArrows(points, toX, toY, color, opts = {}, onAllDone) {
|
|
if (!points.length) { onAllDone?.(); return; }
|
|
let remaining = points.length;
|
|
for (const p of points) {
|
|
this.drawArcArrow(p.x, p.y, toX, toY, color, {
|
|
...opts,
|
|
onDone: () => { if (--remaining === 0) onAllDone?.(); },
|
|
});
|
|
}
|
|
}
|
|
|
|
showBanner(text) {
|
|
const banner = this.add.text(GAME_WIDTH / 2, 300, text, {
|
|
fontFamily: 'Righteous', fontSize: '30px', color: C.text,
|
|
backgroundColor: '#05070fee', padding: { x: 26, y: 12 },
|
|
}).setOrigin(0.5).setDepth(DEPTH.overlay);
|
|
this.tweens.add({
|
|
targets: banner, y: 326, duration: 220, ease: 'Back.easeOut',
|
|
onComplete: () => this.time.delayedCall(950, () =>
|
|
this.tweens.add({ targets: banner, alpha: 0, y: 300, duration: 220, onComplete: () => banner.destroy() })),
|
|
});
|
|
}
|
|
|
|
sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } }
|
|
|
|
// ── game over ───────────────────────────────────────────────────────────────
|
|
onGameOver() {
|
|
if (this._gameOverShown) return;
|
|
this._gameOverShown = true;
|
|
this.clearActionButtons();
|
|
this.closeModal();
|
|
this.setPrompt('');
|
|
const gs = this.gs;
|
|
const won = gs.winner === this.humanSeat;
|
|
const human = gs.players[this.humanSeat];
|
|
const ai = gs.players[1 - this.humanSeat];
|
|
this.recordResult(won ? 'win' : 'loss', ai.lostBases, [human.lostBases]);
|
|
|
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
|
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
|
|
root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive());
|
|
const g = this.add.graphics();
|
|
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(cx - 360, cy - 190, 720, 380, 16);
|
|
g.lineStyle(3, won ? C.gold : C.bad, 1); g.strokeRoundedRect(cx - 360, cy - 190, 720, 380, 16);
|
|
root.add(g);
|
|
root.add(this.add.text(cx, cy - 130, won ? '🏆 THE GALAXY IS YOURS' : 'YOUR LAST BASE HAS FALLEN', {
|
|
fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : C.text,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(cx, cy - 70,
|
|
won ? (human.faction === 'empire' ? 'Order has been restored to the galaxy.' : 'The spark has become a fire.')
|
|
: (human.faction === 'empire' ? 'The Rebellion celebrates in the streets.' : 'The dark times have begun.'), {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: C.muted,
|
|
}).setOrigin(0.5));
|
|
root.add(this.add.text(cx, cy + 6,
|
|
`Enemy bases destroyed — you: ${ai.lostBases} · ${this.seatName(1 - this.humanSeat)}: ${human.lostBases}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '21px', color: C.text,
|
|
}).setOrigin(0.5));
|
|
new Button(this, cx - 115, cy + 120, 'Play Again', () => this.scene.restart(this._initData),
|
|
{ width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1);
|
|
new Button(this, cx + 115, cy + 120, 'Leave', () => this.scene.start('GameMenu'),
|
|
{ width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1);
|
|
}
|
|
|
|
async recordResult(result, score, opponentScores) {
|
|
if (this._recorded) return;
|
|
this._recorded = true;
|
|
try {
|
|
await api.post('/history/single-player', { slug: 'swdbg', score, opponentScores, result });
|
|
} catch { /* best effort */ }
|
|
}
|
|
}
|