fertig-classic-games/src/games/mastervega/VegaShipDetail.js

238 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}