/** * The FACTION DUEL PICKER — the in-game panel for the ?combat=apex / * ?combat=mindweave arena (data/combat.json → combat.factions; the fight * seam is CombatSystem.startDuel / spawnDuel / spawnDuelPack). * * A top-left Phaser panel: the faction's eight fighters as rows * (key 1–8, click works too), row 0 = the whole pack. It is HIDDEN while * a duel is live (combat.duel.active) and re-shows the moment the marked * fighter goes down (killEnemy drops the flag) — pick the next one. * * Keyboard: DIGIT_0 … DIGIT_8 (the keyboard plugin cleans its listeners * up with the scene). No DOM, no data fetch — the rows come from the * faction's own config (EnemyTypes.factionMeta / factionTypeIds). */ import Phaser from '../vendor/phaser.js'; import { factionMeta, factionTypeIds } from './EnemyTypes.js'; const PANEL_W = 360; const ROW_H = 22; const TOP_Y = 64; // under the combat bar's top edge const HEAD_H = 52; // title + tagline const FOOT_H = 34; // the pack row + the hint export class DuelPicker { /** * @param {object} scene the host Phaser scene (GameScene) * @param {object} combat the CombatSystem (its .duel state drives visibility) * @param {string} factionId a combat.factions id ('apex' | 'mindweave' | 'zenith') */ constructor(scene, combat, factionId) { const meta = factionMeta(factionId); this.scene = scene; this.combat = combat; this.factionId = factionId; this._shown = false; if (!meta || !Array.isArray(meta.types) || meta.types.length === 0) { this.container = null; // a bad faction id: no panel, no fight (the boot stays a normal game) return; } this.types = meta.types; this._rowRects = []; // the pick zones (built with the rows below) const w = PANEL_W; const h = HEAD_H + this.types.length * ROW_H + FOOT_H; const x0 = 16, y0 = TOP_Y; const cx = x0 + w / 2, cy = y0 + h / 2; const bg = scene.add.rectangle(cx, cy, w, h, 0x0a0e18, 0.78); bg.setStrokeStyle(1, 0x3a4a6a); const title = scene.add.text(x0 + 12, y0 + 8, String(meta.name ?? factionId).toUpperCase(), { fontFamily: 'monospace', fontSize: '18px', color: '#ffffff', }); const tag = scene.add.text(x0 + 12, y0 + 30, String(meta.tagline ?? ''), { fontFamily: 'monospace', fontSize: '11px', color: '#8fa3c8', }); const rows = []; this.types.forEach((t, i) => { const y = y0 + HEAD_H + i * ROW_H; const label = scene.add.text(x0 + 12, y, `${i + 1} ${t.name ?? t.id}`, { fontFamily: 'monospace', fontSize: '13px', color: '#e8f0ff', }); const style = scene.add.text(x0 + 46, y, `— ${t.style ?? ''}`, { fontFamily: 'monospace', fontSize: '11px', color: '#8fa3c8', }); rows.push(label, style); this._rowRects.push({ i: i + 1, x: x0 + 8, y: y - 8, w: w - 16, h: ROW_H }); }); const pack = scene.add.text(x0 + 12, y0 + HEAD_H + this.types.length * ROW_H, `0 the whole pack — ${this.types.length} fighters at once`, { fontFamily: 'monospace', fontSize: '13px', color: '#ffd84d', }); rows.push(pack); this._rowRects.push({ i: 0, x: x0 + 8, y: y0 + HEAD_H + this.types.length * ROW_H - 8, w: w - 16, h: ROW_H }); const hint = scene.add.text(x0 + 12, y0 + h - 20, 'press the key or click a row — one fighter at a time', { fontFamily: 'monospace', fontSize: '10px', color: '#5f739a', }); this._rowRects.forEach((r) => { // Place the pick zone EXACTLY over its own row. _rowRects is already in // absolute, top-left-anchored screen coords (built from the same x0/y0 // the text rows use), so use it verbatim. The old formula re-added the // panel offset (cx - w/2, cy - h/2) on top, which pushed every zone // THREE rows down — a click on a visible row either missed the zones // entirely or fired the wrong fighter. const hit = scene.add.zone(r.x, r.y, r.w, r.h).setOrigin(0, 0); // v4 (see ActionBar / MenuButton): a bare setInteractive() leaves no // hit-testable area the input manager will actually claim on click, so // re-arm it with an explicit hitArea + callback. The callback receives // (hitArea, localX, localY) — local space of the origin-0 zone. hit.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(0, 0, r.w, r.h), hitAreaCallback: (rect, lx, ly) => (lx >= 0 && lx <= r.w && ly >= 0 && ly <= r.h), }); hit.on('pointerdown', () => this.select(r.i)); rows.push(hit); }); // PIN THE PANEL TO THE SCREEN (scrollFactor 0) — the same hand every // other combat HUD uses. Left at the default scrollFactor 1 the rows // are WORLD-anchored: they land mid-canvas (the camera is centred on // the ship, not on the origin) and drift off-screen the moment the // camera follows the player. Pinned, the panel sits top-left as the // x0/y0 layout intends, and the zone hit-boxes track it in screen // space (Phaser's input accounts for the scroll factor on the pick). [bg, title, tag, ...rows, hint].forEach((o) => { if (o && typeof o.setScrollFactor === 'function') o.setScrollFactor(0); }); this.container = scene.add.container(0, 0, [bg, title, tag, ...rows, hint]); this.container.setDepth(2000); if (typeof this.container.setScrollFactor === 'function') this.container.setScrollFactor(0); // The keyboard hand (digits 0–8). The manager's plain `keydown` event // (the same one the skill seats use) carries the DOM event, so read // e.key / e.code and parse the digit. The DuelPicker only exists in the // faction arena, and there the skill-seats handler no-ops (no class, the // screen is busy), so the digits are free to pick. const kb = scene.input && scene.input.keyboard; if (kb && typeof kb.on === 'function') { kb.on('keydown', (e) => { if (!this.types) return; const raw = `${String(e?.key ?? '')}|${String(e?.code ?? '')}`; const m = /^([0-8])\||^Digit([0-8])$/i.exec(raw); const digit = Number((m && (m[1] ?? m[2])) ?? -1); if (digit >= 0) this.select(digit); }); } } /** * A pick (the key / the row's click): 0 = the whole pack, 1–8 = the * fighter at that row. Replaces any live duel ship (a rematch is a * fresh craft). A pick while the duel is live is a RE-PICK (allowed — * the picker is hidden while live, so this path is the keyboard's). */ select(n) { const combat = this.combat; if (!combat || !this.types) return; if (n === 0) { combat.spawnDuelPack(this.factionId); return; } const t = this.types[n - 1]; if (t && typeof t.id === 'string') combat.spawnDuel(t.id); } /** The picker's frame step (GameScene.update): visibility = duel not live. */ update(_time) { if (!this.container) return; const d = this.combat.duel; if (!d) return; const show = !d.active; if (show === this._shown) return; this._shown = show; this.container.setVisible(show); } /** The panel's rows (a test/dev seam). */ rows() { return (this.types ?? []).map((t, i) => ({ key: i + 1, id: t.id, name: t.name })); } }