238 lines
9.0 KiB
JavaScript
238 lines
9.0 KiB
JavaScript
// 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 };
|
||
}
|