// Master of Vega — the ship detail pop-over. // // Clicking any ship row anywhere opens this: the commanding officer at the full // 256px the clips are recorded at, the hull at 192px, and everything the empire // knows about the class — the hull's own description from the rules file, the // stat block the Mark currently resolves to, and the loadout the knapsack put // on it. // // It deliberately does NOT go through MasterOfVegaGame.openModal, which refuses // a second modal while one is up. This window opens from the star map's side // panel (nothing else open) and from inside the colony screen (which is itself // a full-screen layer that must stay up), so it draws its own veil at D.detail // and clears both. // // Chrome and the grow-from-nothing entrance mirror openSpeciesDetail: built at // full size and scaled up, so the type renders crisp at its final size instead // of as a magnified thumbnail. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { markNumeral } from './VegaRules.js'; import { empireDesign } from './VegaLogic.js'; import { refitCost } from './VegaShips.js'; import { makeCommanderPortrait, makeShipIcon } from './VegaShipMedia.js'; import { FONT, D, uiClick } from './VegaScreens.js'; import { playSound, SFX } from '../../ui/Sounds.js'; const ACCENT = 0x6fc4ff; const PANEL = 0x0b1220; const PANEL_W = 1000; const PANEL_H = 700; const ROLE_NAMES = { recon: 'Reconnaissance', colony: 'Colonisation', troops: 'Troop carrier', warship: 'Warship', base: 'Orbital fortress', }; /** * @param {object} opts { empireIdx, hullId, mark } — `mark` is the stack's own * Mark where the caller has one (a fleet row); omit it for a catalogue entry, * which is always quoted at the current Mark. * @param {function} onClose called after the window has finished closing. * @returns {{ close: function }} */ export function openShipDetail(scene, rules, state, art, opts, onClose) { const { empireIdx, hullId, mark = null } = opts; playSound(scene, SFX.VEGA_UNIT); const emp = state.empires[empireIdx]; const species = rules.species[emp.speciesId]; const design = empireDesign(rules, state, empireIdx, hullId); const hull = design.hull; const halfW = PANEL_W / 2; const halfH = PANEL_H / 2; const accent = Phaser.Display.Color.HexStringToColor(species.color).color; const layer = scene.add.container(0, 0).setDepth(D.detail); const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0) .setOrigin(0, 0).setInteractive(); layer.add(veil); scene.tweens.add({ targets: veil, fillAlpha: 0.8, duration: 180 }); const pop = scene.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2); pop.setScale(0.2).setAlpha(0.3); layer.add(pop); const panel = scene.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, PANEL, 0.97).setOrigin(0, 0); panel.setStrokeStyle(2, accent, 0.85); pop.add(panel); const ticks = scene.add.graphics(); ticks.lineStyle(3, accent, 0.9); const t = 30; for (const [tx, ty, dx, dy] of [ [-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1], [-halfW, halfH, 1, -1], [halfW, halfH, -1, -1], ]) { ticks.lineBetween(tx, ty, tx + dx * t, ty); ticks.lineBetween(tx, ty, tx, ty + dy * t); } pop.add(ticks); // ------------------------------------------------------------ left column // The officer at full recording resolution, the hull beneath them. This is // the window's own portrait, not one borrowed from the caller's pool — the // caller pauses its thumbnails while we are up, so nothing decodes twice. const artX = -halfW + 176; pop.add(makeCommanderPortrait(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 190, 256)); pop.add(scene.add.text(artX, -halfH + 336, 'COMMANDING OFFICER', { fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', }).setOrigin(0.5)); pop.add(makeShipIcon(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 470, 192)); pop.add(scene.add.text(artX, halfH - 78, species.name.toUpperCase(), { fontFamily: FONT, fontSize: '18px', color: species.color, }).setOrigin(0.5)); // ----------------------------------------------------------- right column const colX = -halfW + 340; const colW = PANEL_W - 40 - (colX + halfW); let y = -halfH + 44; const text = (str, size, colour, gap = 6, wrap = colW) => { const o = scene.add.text(colX, y, str, { fontFamily: FONT, fontSize: `${size}px`, color: colour, lineSpacing: 3, wordWrap: { width: wrap }, }); pop.add(o); y += o.height + gap; return o; }; const heading = (label) => { y += 10; pop.add(scene.add.text(colX, y, label, { fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', })); y += 22; pop.add(scene.add.rectangle(colX, y, colW, 1, ACCENT, 0.22).setOrigin(0, 0)); y += 10; }; text(design.name, 38, '#cfe8ff', 2); text(`${ROLE_NAMES[hull.role] ?? hull.role} · ${design.cost} BC`, 17, '#9fd8ff', 8); text(hull.desc, 18, '#a8c4e0', 4); // --- the stat block, two columns of label/value pairs heading('SPECIFICATION'); const stats = [ ['Hull', `${design.hp} HP`], ['Armour', design.armorName], ['Shields', design.shield > 0 ? `Class ${design.shield}` : 'None'], ['Engines', design.engineName], ['Speed', design.immobile ? 'Immobile' : `${design.speed}`], ['Range', design.immobile ? '—' : `${design.range} parsecs`], ['Targeting', `+${design.targeting}`], ['Initiative', `${design.initiative}`], ]; if (design.attack || design.defense) { stats.push(['Attack', `${design.attack >= 0 ? '+' : ''}${design.attack}%`]); stats.push(['Defense', `${design.defense >= 0 ? '+' : ''}${design.defense}%`]); } if (design.repairPerRound > 0) { stats.push(['Repair', `${Math.round(design.repairPerRound * 100)}% / round`]); } if (design.troops > 0) stats.push(['Marines', `${design.troops} divisions`]); const statTop = y; const cellW = colW / 2; stats.forEach((pair, i) => { const cx = colX + (i % 2) * cellW; const cy = statTop + Math.floor(i / 2) * 30; pop.add(scene.add.text(cx, cy, pair[0], { fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', })); pop.add(scene.add.text(cx + 120, cy, String(pair[1]), { fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', })); }); y = statTop + Math.ceil(stats.length / 2) * 30 + 4; const flags = []; if (design.cloaked) flags.push('Cloaked'); if (design.planetCracker) flags.push('Planet cracker'); if (design.immobile) flags.push('Never leaves its system'); if (flags.length) text(flags.join(' · '), 15, '#ffd88a', 4); // --- the loadout the knapsack settled on heading('LOAD-OUT'); if (hull.space <= 0) { text('Unarmed — this hull carries no weapon tonnage.', 16, '#7f97b3', 4); } else if (!design.mounts.length) { text('No weapons researched yet. This hull builds, and flies empty.', 16, '#e08a8a', 4); } else { for (const m of design.mounts) { const w = m.weapon; const dmg = w.min === w.max ? `${w.min}` : `${w.min}–${w.max}`; const tail = w.kind === 'missile' ? `${w.shots} salvo${w.shots === 1 ? '' : 'es'}` : 'every round'; text(`${m.count} × ${w.name} · ${dmg} damage · ${tail}` + (w.shieldPierce ? ` · pierces ${w.shieldPierce} shield` : ''), 17, '#9fd8ff', 3); } y += 4; const summary = []; if (design.beamDamage > 0) summary.push(`Beams ${Math.round(design.beamDamage)} per round`); if (design.missileSalvo > 0) { summary.push(`Missiles ${Math.round(design.missileSalvo)} × ${design.missileSalvos} salvoes`); } summary.push(`${hull.space} tonnage`); text(summary.join(' · '), 15, '#7f97b3', 4); } // A stack that has not been refitted yet is flying an older Mark. The older // Mark's LOADOUT cannot be reconstructed — designFor only ever builds at the // empire's current tech — so say plainly that the block above is the new one. if (mark !== null && mark < design.mark) { const cost = refitCost(rules, emp.known, hullId, mark, species.traits); text(`This stack is still Mark ${markNumeral(mark)}. The figures above are the ` + `Mark ${markNumeral(design.mark)} refit, which costs ${cost} BC and happens ` + 'automatically over a friendly colony.', 15, '#ffd88a', 4); } // ------------------------------------------------------------------ close let closing = false; const close = () => { if (closing) return; closing = true; scene.tweens.add({ targets: veil, fillAlpha: 0, duration: 160 }); scene.tweens.add({ targets: pop, scale: 0.2, alpha: 0.25, duration: 180, ease: 'Cubic.easeIn', onComplete: () => { layer.destroy(); onClose?.(); }, }); }; pop.add(new Button(scene, halfW - 110, halfH - 52, 'Close', uiClick(scene, close), { width: 180, height: 52, fontSize: 22, variant: 'ghost' })); veil.on('pointerup', close); scene.tweens.add({ targets: pop, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut', }); return { close }; }