Compare commits
3 Commits
c105ebd23c
...
167fdd6e3f
| Author | SHA1 | Date |
|---|---|---|
|
|
167fdd6e3f | |
|
|
4c3b17610a | |
|
|
e1b5ae7277 |
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { TextInput } from '../../ui/TextInput.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { VegaMusic } from './VegaMusic.js';
|
||||
|
|
@ -47,6 +47,17 @@ const SAVE_KEY = 'mastervega-save';
|
|||
const SAVE_SLOT_COUNT = 10;
|
||||
const saveSlotKey = (i) => `mastervega-save-slot-${i}`;
|
||||
|
||||
// The Empire button's own leading open/closed indicator — swapped by
|
||||
// open/closeEmpireMenu, same ▸/▾ convention VegaTurnReportScreen.js uses
|
||||
// per-row, applied here to a button label instead. The button's label is
|
||||
// one centered Phaser Text (VegaButton.js has no separate icon slot), so
|
||||
// the only way to nudge the triangle further from "Empire" without a new
|
||||
// component is padding the string itself — the extra spaces push the
|
||||
// triangle left and "Empire" right by equal amounts as the whole centered
|
||||
// block widens.
|
||||
const EMPIRE_CLOSED_LABEL = '▸ Empire';
|
||||
const EMPIRE_OPEN_LABEL = '▾ Empire';
|
||||
|
||||
export default class MasterOfVegaGame extends Phaser.Scene {
|
||||
constructor() { super('MasterOfVegaGame'); }
|
||||
|
||||
|
|
@ -648,27 +659,24 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
this.hudText = this.add.text(24, 18, '', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff' });
|
||||
hud.add(this.hudText);
|
||||
|
||||
const mk = (label, x, fn) => {
|
||||
const b = new Button(this, x, 31, label, this.uiClick(fn), { width: 150, height: 42, fontSize: 18 });
|
||||
hud.add(b);
|
||||
return b;
|
||||
};
|
||||
mk('Research', GAME_WIDTH - 830, () => this.openModal((done) =>
|
||||
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done)));
|
||||
mk('Diplomacy', GAME_WIDTH - 670, () => this.openModal((done) =>
|
||||
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
||||
() => this.refreshAll())));
|
||||
mk('Council', GAME_WIDTH - 510, () => this.openModal((done) =>
|
||||
openCouncilScreen(this, this.rules, this.state, done)));
|
||||
mk('Leaders', GAME_WIDTH - 350, () => this.openModal((done) =>
|
||||
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
||||
() => this.refreshHud())));
|
||||
// Research/Diplomacy/Leaders/Council used to be four separate HUD
|
||||
// buttons; consolidated into one "Empire" dropdown (openEmpireMenu,
|
||||
// near the game menu below) to leave room in the bar for more empire-
|
||||
// management buttons later (Brian's plan: "Colonies" next) without the
|
||||
// row running out of width. Parked immediately left of End Turn, same
|
||||
// width, so the two read as a pair — the leading ▸/▾ (EMPIRE_CLOSED_LABEL/
|
||||
// EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's
|
||||
// own open/closed indicator, not the same glyph VegaTurnReportScreen.js
|
||||
// uses per-row, just the same visual convention.
|
||||
this.empireBtn = new Button(this, GAME_WIDTH - 340, 31, EMPIRE_CLOSED_LABEL,
|
||||
this.uiClick(() => this.toggleEmpireMenu()), { width: 190, height: 42, fontSize: 18 });
|
||||
hud.add(this.empireBtn);
|
||||
|
||||
// Not this.uiClick — onEndTurn plays its own vega-endturn cue (shared with
|
||||
// combat's Next round button) rather than the generic click, and a no-op
|
||||
// press while busy/modal should stay silent.
|
||||
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
|
||||
{ width: 190, height: 46, fontSize: 20 });
|
||||
{ width: 190, height: 46, fontSize: 20, scheme: 'magenta' });
|
||||
hud.add(this.endTurnBtn);
|
||||
|
||||
// --- status log, bottom left
|
||||
|
|
@ -749,6 +757,72 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
this.gameMenuLayer = null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- empire menu
|
||||
|
||||
/** Empire toggles a small popover dropping down from directly below it:
|
||||
* Research, Diplomacy, Leaders, Council, in that order (Brian's ask).
|
||||
* Same shape as the game menu above — click-anywhere-to-dismiss veil,
|
||||
* ghost-variant item buttons — just anchored under the HUD bar instead of
|
||||
* above the ☰ button. Gated by modalOpen like every other HUD button. */
|
||||
toggleEmpireMenu() {
|
||||
if (this.modalOpen) return;
|
||||
if (this.empireMenuLayer) { this.closeEmpireMenu(); return; }
|
||||
this.openEmpireMenu();
|
||||
}
|
||||
|
||||
openEmpireMenu() {
|
||||
this.empireBtn.setLabel(EMPIRE_OPEN_LABEL);
|
||||
const layer = this.add.container(0, 0).setDepth(D.modal);
|
||||
this.empireMenuLayer = layer;
|
||||
|
||||
const catcher = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
|
||||
.setOrigin(0, 0).setInteractive();
|
||||
catcher.on('pointerup', () => this.closeEmpireMenu());
|
||||
layer.add(catcher);
|
||||
|
||||
const items = [
|
||||
['Research', () => this.openModal((done) =>
|
||||
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done))],
|
||||
['Diplomacy', () => this.openModal((done) =>
|
||||
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
||||
() => this.refreshAll()))],
|
||||
['Leaders', () => this.openModal((done) =>
|
||||
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
||||
() => this.refreshHud()))],
|
||||
['Council', () => this.openModal((done) =>
|
||||
openCouncilScreen(this, this.rules, this.state, done))],
|
||||
];
|
||||
|
||||
const BTN_W = 200;
|
||||
const BTN_H = 42;
|
||||
const GAP = 8;
|
||||
const PAD = 14;
|
||||
const panelH = PAD * 2 + items.length * BTN_H + (items.length - 1) * GAP;
|
||||
// Left-aligned under the (190-wide) Empire button, regardless of the
|
||||
// dropdown panel's own width — reads empireBtn's actual width rather
|
||||
// than a second hardcoded constant that could drift if either changes.
|
||||
const panelCx = this.empireBtn.x - this.empireBtn.options.width / 2 + BTN_W / 2;
|
||||
const panelTopY = 31 + 21 + 12; // just below the Empire button (centred at y=31, height 42)
|
||||
const panelCy = panelTopY + panelH / 2;
|
||||
|
||||
layer.add(this.add.rectangle(panelCx, panelCy, BTN_W + PAD * 2, panelH, 0x0b1220, 0.97)
|
||||
.setStrokeStyle(1.5, 0x6fc4ff, 0.55));
|
||||
|
||||
let by = panelCy - panelH / 2 + PAD + BTN_H / 2;
|
||||
for (const [label, fn] of items) {
|
||||
const btn = new Button(this, panelCx, by, label, this.uiClick(() => { this.closeEmpireMenu(); fn(); }),
|
||||
{ width: BTN_W, height: BTN_H, fontSize: 16, variant: 'ghost' });
|
||||
layer.add(btn);
|
||||
by += BTN_H + GAP;
|
||||
}
|
||||
}
|
||||
|
||||
closeEmpireMenu() {
|
||||
this.empireBtn.setLabel(EMPIRE_CLOSED_LABEL);
|
||||
this.empireMenuLayer?.destroy();
|
||||
this.empireMenuLayer = null;
|
||||
}
|
||||
|
||||
// Both destinations auto-save to the single "Resume Game" slot on the way
|
||||
// out, same safety net the old ← Menu button always had — independent of,
|
||||
// and in addition to, the 10 manual slots below.
|
||||
|
|
@ -820,9 +894,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
const GAP = 10;
|
||||
const X = 24;
|
||||
const PORTRAIT = 48;
|
||||
// canNegotiate here is a belt-and-suspenders guard, not the primary fix:
|
||||
// runDiplomacyTurn (VegaDiplomacy.js) no longer lets a diplomacy-
|
||||
// incapable species (Lithox) queue a pendingOffer in the first place, but
|
||||
// this keeps a save written before that fix from resurrecting a stray
|
||||
// "seeks an audience" card with a permanently-disabled button.
|
||||
const otherIdxs = Object.keys(human.pendingOffers)
|
||||
.map(Number)
|
||||
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive);
|
||||
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive
|
||||
&& canNegotiate(this.rules, this.state, this.state.humanIndex, idx));
|
||||
otherIdxs.forEach((otherIdx, row) => {
|
||||
const other = this.state.empires[otherIdx];
|
||||
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
|
|
@ -729,12 +729,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
|||
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
|
||||
function layoutRow(list, y, extraOpts = {}) {
|
||||
const perRow = 3;
|
||||
list.forEach(([label, fn], i) => {
|
||||
list.forEach(([label, fn, itemOpts], i) => {
|
||||
const col = i % perRow;
|
||||
const row = Math.floor(i / perRow);
|
||||
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
|
||||
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
|
||||
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts }));
|
||||
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts, ...itemOpts }));
|
||||
});
|
||||
return Math.ceil(list.length / perRow) || 0;
|
||||
}
|
||||
|
|
@ -749,15 +749,15 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
|||
items.push(['We\'re Staying', doComplaintDefy]);
|
||||
}
|
||||
if (offer) {
|
||||
items.push(['Accept', () => doPending(true)]);
|
||||
items.push(['Reject', () => doPending(false)]);
|
||||
items.push(['Accept', () => doPending(true), { scheme: 'green' }]);
|
||||
items.push(['Reject', () => doPending(false), { scheme: 'red' }]);
|
||||
} else if (!canNegotiate(rules, state, me, otherIdx)) {
|
||||
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]);
|
||||
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
|
||||
} else if (atWar(state, me, otherIdx)) {
|
||||
items.push(['Sue for Peace', () => doPropose('peace')]);
|
||||
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
|
||||
} else {
|
||||
items.push(['Declare War', doDeclareWar]);
|
||||
items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
|
||||
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
|
||||
items.push(['Propose Alliance', () => doPropose('alliance')]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,343 @@
|
|||
// Master of Vega — cyberpunk button skin.
|
||||
//
|
||||
// Same public API as src/ui/Button.js (constructor signature, options,
|
||||
// setActive/setLabel/setEnabled) so every call site in this game can swap
|
||||
// its import for this file with zero other changes. Deliberately NOT a
|
||||
// replacement for src/ui/Button.js itself — that file is shared with other
|
||||
// games and stays untouched; this is a mastervega-local reskin only.
|
||||
//
|
||||
// Look: angular chamfered panel (not rounded), a static neon glow on the
|
||||
// border (baseline look, not a hover animation), and hover-only animations —
|
||||
// two upper-right/lower-left corner brackets snapping outward like a
|
||||
// targeting reticle acquiring a lock, an occasional diagonal shimmer
|
||||
// crossing the face, and a few small white lights slowly orbiting the
|
||||
// button's own stroke outline, drawn above everything else in the button.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
const CHAMFER = 14;
|
||||
|
||||
const SCHEMES = {
|
||||
cyan: { glow: 0x2fe3ff, textDark: '#04121e' },
|
||||
magenta: { glow: 0xff2fd0, textDark: '#1a0414' },
|
||||
red: { glow: 0xff3355, textDark: '#1a0406' },
|
||||
green: { glow: 0x2bff9e, textDark: '#04160e' },
|
||||
};
|
||||
|
||||
const PANEL = 0x0b1220;
|
||||
const TEXT = '#e8f4ff';
|
||||
// Every button carries an always-on postFX glow on its border (see
|
||||
// bgRect.postFX.addGlow below), meant to read as "dark button, glowing
|
||||
// edge." Solid buttons (fill alpha 1) stay dark under that glow because
|
||||
// the opaque fill visually dominates it. Ghost buttons don't have that
|
||||
// luxury — at a low fill alpha there isn't enough opaque dark color to
|
||||
// resist the glow, so the whole button washes out to a flat bright cyan
|
||||
// instead of reading as translucent-with-a-glowing-edge. 0.85 gives ghost
|
||||
// buttons enough opacity to hold their own against the glow while staying
|
||||
// visibly lighter/less final than a solid button.
|
||||
const GHOST_ALPHA = 0.85;
|
||||
|
||||
export class Button extends Phaser.GameObjects.Container {
|
||||
constructor(scene, x, y, label, onClick, options = {}) {
|
||||
super(scene, x, y);
|
||||
const {
|
||||
width = 280,
|
||||
height = 64,
|
||||
bg = PANEL,
|
||||
textColor = TEXT,
|
||||
fontSize = 28,
|
||||
variant = 'solid',
|
||||
scheme = 'cyan',
|
||||
} = options;
|
||||
|
||||
const scm = SCHEMES[scheme] || SCHEMES.cyan;
|
||||
this.options = { width, height, bg, textColor, fontSize, variant, scheme: scm };
|
||||
// Cached once — _chamferPoints() only depends on width/height, which
|
||||
// never change after construction — so the border-lights orbit
|
||||
// (_perimeterPoint) doesn't recompute the polygon's arc-length table
|
||||
// on every animation frame.
|
||||
this._perimeter = this._computePerimeter();
|
||||
|
||||
const isGhost = variant === 'ghost';
|
||||
|
||||
this.bgRect = scene.add.graphics();
|
||||
this.bgRect.postFX.addGlow(scm.glow, 3, 0, false, 0.1, 10);
|
||||
|
||||
// Shimmer + corner-bracket overlays, chamfer-clipped so they never
|
||||
// spill past the panel outline. Created before the first _drawBg() call
|
||||
// since that call populates fxMaskShape's geometry.
|
||||
this.fxMaskShape = scene.add.graphics().setVisible(false);
|
||||
this.shimmer = scene.add.graphics().setAlpha(0);
|
||||
this.shimmer.setMask(new Phaser.Display.Masks.GeometryMask(scene, this.fxMaskShape));
|
||||
this.brackets = scene.add.graphics().setAlpha(0);
|
||||
// Unmasked (unlike shimmer/brackets, which stay inside or hug the
|
||||
// panel) — its points are computed directly on the stroke's own path,
|
||||
// so it never needs clipping, and it is added to the container LAST,
|
||||
// below, so it renders above bgRect's stroke rather than under it.
|
||||
this.borderLights = scene.add.graphics().setAlpha(0);
|
||||
|
||||
this._drawBg(bg, isGhost ? GHOST_ALPHA : 1);
|
||||
|
||||
this.text = scene.add.text(0, 0, label, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: `${fontSize}px`,
|
||||
color: textColor,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Long labels on a fixed-width button otherwise render past the edge of
|
||||
// the chamfered panel. Purely corrective: only ever shrinks text that
|
||||
// wouldn't have fit at the requested size, so every button whose label
|
||||
// already fits renders exactly as before.
|
||||
const maxTextWidth = width - 16;
|
||||
if (this.text.width > maxTextWidth) {
|
||||
const fitSize = Math.max(11, Math.floor(fontSize * (maxTextWidth / this.text.width)));
|
||||
this.text.setFontSize(fitSize);
|
||||
}
|
||||
|
||||
this._drawBrackets(false);
|
||||
|
||||
// borderLights is last — Phaser Containers render children in insertion
|
||||
// order regardless of individual depth, so this is what actually puts
|
||||
// it above bgRect's stroke (and everything else) rather than under it.
|
||||
this.add([this.bgRect, this.fxMaskShape, this.shimmer, this.brackets, this.text, this.borderLights]);
|
||||
|
||||
this.setSize(width, height);
|
||||
this.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Rectangle(0, 0, width, height),
|
||||
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
|
||||
});
|
||||
|
||||
const onOver = () => {
|
||||
if (this._active) return;
|
||||
const { scheme: s, variant: v } = this.options;
|
||||
if (v === 'ghost') {
|
||||
this._drawBg(s.glow, 0.22);
|
||||
this.text.setColor(s.textDark);
|
||||
} else {
|
||||
this._drawBg(s.glow, 1);
|
||||
this.text.setColor(s.textDark);
|
||||
}
|
||||
this._startShimmer();
|
||||
this._startBorderLights();
|
||||
this._drawBrackets(true);
|
||||
};
|
||||
const onOut = () => {
|
||||
if (this._active) return;
|
||||
const { bg: b, textColor: tc, variant: v } = this.options;
|
||||
this._drawBg(b, v === 'ghost' ? GHOST_ALPHA : 1);
|
||||
this.text.setColor(tc);
|
||||
this._stopShimmer();
|
||||
this._stopBorderLights();
|
||||
this._drawBrackets(false);
|
||||
};
|
||||
const onDown = () => this.bgRect.setScale(0.97);
|
||||
const onUp = () => this.bgRect.setScale(1);
|
||||
|
||||
this.on('pointerover', onOver);
|
||||
this.on('pointerout', onOut);
|
||||
this.on('pointerdown', onDown);
|
||||
this.on('pointerup', onUp);
|
||||
this.on('pointerupoutside', onUp);
|
||||
if (onClick) this.on('pointerup', onClick);
|
||||
|
||||
this.once(Phaser.GameObjects.Events.DESTROY, () => {
|
||||
this._stopShimmer();
|
||||
this._stopBorderLights();
|
||||
});
|
||||
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
/** Chamfered (angular-cut) panel outline, top-right and bottom-left corners cut at 45°. */
|
||||
_chamferPoints() {
|
||||
const { width, height } = this.options;
|
||||
const hw = width / 2;
|
||||
const hh = height / 2;
|
||||
const c = Math.min(CHAMFER, hw, hh);
|
||||
return [
|
||||
{ x: -hw, y: -hh },
|
||||
{ x: hw - c, y: -hh },
|
||||
{ x: hw, y: -hh + c },
|
||||
{ x: hw, y: hh },
|
||||
{ x: -hw + c, y: hh },
|
||||
{ x: -hw, y: hh - c },
|
||||
];
|
||||
}
|
||||
|
||||
_drawBg(fillColor, fillAlpha) {
|
||||
const { scheme } = this.options;
|
||||
const pts = this._chamferPoints();
|
||||
this.bgRect.clear();
|
||||
if (fillAlpha > 0) {
|
||||
this.bgRect.fillStyle(fillColor, fillAlpha);
|
||||
this.bgRect.fillPoints(pts, true);
|
||||
}
|
||||
this.bgRect.lineStyle(2, scheme.glow, 1);
|
||||
this.bgRect.strokePoints(pts, true);
|
||||
|
||||
this.fxMaskShape.clear();
|
||||
this.fxMaskShape.fillStyle(0xffffff, 1);
|
||||
this.fxMaskShape.fillPoints(pts, true);
|
||||
}
|
||||
|
||||
_drawBrackets(extended) {
|
||||
const { width, height, scheme } = this.options;
|
||||
const hw = width / 2;
|
||||
const hh = height / 2;
|
||||
const arm = 10;
|
||||
const pad = extended ? 4 : 0;
|
||||
// Only the upper-right and lower-left corners (Brian's ask) — these are
|
||||
// also the two the chamfered panel itself cuts at 45° (_chamferPoints),
|
||||
// so the brackets echo the panel's own shape instead of framing all
|
||||
// four corners generically.
|
||||
const corners = [
|
||||
{ cx: hw + pad, cy: -hh - pad, dx: -1, dy: 1 },
|
||||
{ cx: -hw - pad, cy: hh + pad, dx: 1, dy: -1 },
|
||||
];
|
||||
this.brackets.clear();
|
||||
this.brackets.lineStyle(2, scheme.glow, 1);
|
||||
for (const { cx, cy, dx, dy } of corners) {
|
||||
this.brackets.beginPath();
|
||||
this.brackets.moveTo(cx + dx * arm, cy);
|
||||
this.brackets.lineTo(cx, cy);
|
||||
this.brackets.lineTo(cx, cy + dy * arm);
|
||||
this.brackets.strokePath();
|
||||
}
|
||||
}
|
||||
|
||||
// A tall, narrow, tilted bright band tweened across in X — much taller
|
||||
// than the button so the tilt never uncovers the top/bottom edge as it
|
||||
// crosses — clipped to the chamfer shape by the shimmer/fxMaskShape mask
|
||||
// set up in the constructor, then a repeatDelay pause before it crosses
|
||||
// again: "occasional", not a continuous back-and-forth sweep.
|
||||
_startShimmer() {
|
||||
this._stopShimmer();
|
||||
const { width, height } = this.options;
|
||||
const hw = width / 2;
|
||||
const margin = height; // clears the band fully off both sides at rest
|
||||
this.shimmer.clear();
|
||||
this.shimmer.fillStyle(0xffffff, 0.5);
|
||||
this.shimmer.fillRect(-12, -height * 2, 24, height * 4);
|
||||
this.shimmer.rotation = Phaser.Math.DegToRad(22);
|
||||
this.shimmer.x = -hw - margin;
|
||||
this.shimmer.y = 0;
|
||||
this.shimmer.setAlpha(1);
|
||||
this.brackets.setAlpha(1);
|
||||
this._shimmerTween = this.scene.tweens.add({
|
||||
targets: this.shimmer,
|
||||
x: hw + margin,
|
||||
duration: 550,
|
||||
repeat: -1,
|
||||
repeatDelay: 1500,
|
||||
ease: 'Cubic.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
_stopShimmer() {
|
||||
if (this._shimmerTween) {
|
||||
this._shimmerTween.stop();
|
||||
this._shimmerTween = null;
|
||||
}
|
||||
this.shimmer.setAlpha(0);
|
||||
this.brackets.setAlpha(0);
|
||||
}
|
||||
|
||||
// Arc-length table for _perimeterPoint: cumulative distance walked around
|
||||
// the chamfered polygon, point by point, closing back to the start.
|
||||
_computePerimeter() {
|
||||
const pts = this._chamferPoints();
|
||||
const cum = [0];
|
||||
for (let i = 0; i < pts.length; i += 1) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
cum.push(cum[i] + Phaser.Math.Distance.Between(a.x, a.y, b.x, b.y));
|
||||
}
|
||||
return { pts, cum, total: cum[cum.length - 1] };
|
||||
}
|
||||
|
||||
/** Point at `frac` (0..1, wraps) of the way around the perimeter. */
|
||||
_perimeterPoint(frac) {
|
||||
const { pts, cum, total } = this._perimeter;
|
||||
const target = (((frac % 1) + 1) % 1) * total;
|
||||
for (let i = 0; i < pts.length; i += 1) {
|
||||
if (target <= cum[i + 1] || i === pts.length - 1) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const segLen = cum[i + 1] - cum[i];
|
||||
const t = segLen > 0 ? (target - cum[i]) / segLen : 0;
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
}
|
||||
return pts[0];
|
||||
}
|
||||
|
||||
// A few short white segments, evenly spaced around the perimeter, redrawn
|
||||
// each tick as `phase` advances — the "orbiting lights" effect. Each
|
||||
// segment is a straight line between two perimeter points rather than a
|
||||
// true corner-following polyline, so a segment straddling a corner cuts
|
||||
// it very slightly; negligible at this segment length, not worth a
|
||||
// multi-point path for.
|
||||
_drawBorderLights(phase) {
|
||||
const LIGHT_COUNT = 3;
|
||||
const LIGHT_FRAC = 0.05;
|
||||
this.borderLights.clear();
|
||||
this.borderLights.lineStyle(3, 0xffffff, 0.9);
|
||||
for (let i = 0; i < LIGHT_COUNT; i += 1) {
|
||||
const start = phase + i / LIGHT_COUNT;
|
||||
const a = this._perimeterPoint(start);
|
||||
const b = this._perimeterPoint(start + LIGHT_FRAC);
|
||||
this.borderLights.beginPath();
|
||||
this.borderLights.moveTo(a.x, a.y);
|
||||
this.borderLights.lineTo(b.x, b.y);
|
||||
this.borderLights.strokePath();
|
||||
}
|
||||
}
|
||||
|
||||
_startBorderLights() {
|
||||
this._stopBorderLights();
|
||||
const counter = { phase: 0 };
|
||||
this.borderLights.setAlpha(1);
|
||||
this._lightsTween = this.scene.tweens.add({
|
||||
targets: counter,
|
||||
phase: 1,
|
||||
duration: 3600, // slow, per Brian's ask
|
||||
repeat: -1,
|
||||
onUpdate: () => this._drawBorderLights(counter.phase),
|
||||
});
|
||||
}
|
||||
|
||||
_stopBorderLights() {
|
||||
if (this._lightsTween) {
|
||||
this._lightsTween.stop();
|
||||
this._lightsTween = null;
|
||||
}
|
||||
this.borderLights.clear();
|
||||
this.borderLights.setAlpha(0);
|
||||
}
|
||||
|
||||
setActive(active) {
|
||||
this._active = active;
|
||||
const { bg, textColor, scheme, variant } = this.options;
|
||||
if (active) {
|
||||
this._drawBg(scheme.glow, 1);
|
||||
this.text.setColor(scheme.textDark);
|
||||
} else {
|
||||
this._drawBg(bg, variant === 'ghost' ? GHOST_ALPHA : 1);
|
||||
this.text.setColor(textColor);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
setLabel(label) {
|
||||
this.text.setText(label);
|
||||
return this;
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
this.setAlpha(enabled ? 1 : 0.5);
|
||||
if (enabled) this.setInteractive({ useHandCursor: true });
|
||||
else this.disableInteractive();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { TextInput } from '../../ui/TextInput.js';
|
||||
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
|
||||
import { turnToYear } from './VegaRules.js';
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
|
|
@ -82,7 +82,7 @@ const COLONY_FOCUS_OPTIONS = [
|
|||
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
|
||||
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
|
||||
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
|
||||
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet, favouring whichever hull you have the fewest of at this system.' },
|
||||
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' },
|
||||
{ value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' },
|
||||
{ value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
|
||||
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { queueGameAssets } from '../../services/assetLoader.js';
|
||||
import { compileRules, markNumeral } from './VegaRules.js';
|
||||
import { ensureSheets } from './VegaArt.js';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||
import VegaFx from './VegaFx.js';
|
||||
|
|
@ -205,7 +205,7 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
|
|||
playSound(scene, SFX.VEGA_ENDTURN);
|
||||
animate(stepRound(battle, {}));
|
||||
if (battle.done) scene.time.delayedCall(700, finish);
|
||||
}, { width: 220, height: 52 });
|
||||
}, { width: 220, height: 52, scheme: 'magenta' });
|
||||
layer.add(next);
|
||||
|
||||
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||
import VegaFx from './VegaFx.js';
|
||||
|
|
|
|||
|
|
@ -275,8 +275,17 @@ export function runDiplomacyTurn(rules, state, e) {
|
|||
if (atWar(state, e, other.idx)) {
|
||||
// Sue for peace when clearly losing, or when the war has gone cold.
|
||||
// Only sue for peace when genuinely losing, and not immediately — a war
|
||||
// that ends on the turn it starts accomplishes nothing.
|
||||
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace');
|
||||
// that ends on the turn it starts accomplishes nothing. Gated on
|
||||
// canNegotiate same as every other proposal below: a diplomacy-
|
||||
// incapable species (Lithox) never comes to the table, war included —
|
||||
// the only way out of a war with one is the human's own unilateral
|
||||
// Cease Hostilities toggle (VegaScreens.js), not a held offer. Without
|
||||
// this a losing Lithox would still queue a peace offer the human could
|
||||
// never answer (its Seek Audience button is permanently disabled),
|
||||
// leaving a stray "seeks an audience" card up for nothing.
|
||||
if (canNegotiate(rules, state, e, other.idx) && ratio < 0.55 && rand(state) < 0.12) {
|
||||
proposeOrOffer(rules, state, e, other.idx, 'peace');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1513,6 +1513,24 @@ function deliverPopulation(rules, state, f) {
|
|||
stack.popPayload = 0;
|
||||
}
|
||||
|
||||
// Resolves which colony at this star a bombard/invade action actually
|
||||
// targets. A star can host more than one colony (one per orbit), owned by
|
||||
// different empires — colonyAt() only ever returns the first one found in
|
||||
// state.colonies, regardless of orbit. That silently broke Bombard/Invade
|
||||
// whenever an allied or neutral colony shared a system with the actual
|
||||
// (hostile) target: colonyAt picked the ally, the atWar check correctly
|
||||
// refused, and the caller had no way to tell the refusal apart from a real
|
||||
// "no orbital superiority" failure. `orbit`, when supplied, pins the exact
|
||||
// colony the caller means — VegaSystemView.js always knows which one is on
|
||||
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the
|
||||
// fallback targets whichever colony here the attacker is actually at war
|
||||
// with, rather than just whichever happens to be first in the array.
|
||||
function targetColonyAt(state, e, starIdx, orbit) {
|
||||
const here = coloniesAt(state, starIdx);
|
||||
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
|
||||
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
|
||||
}
|
||||
|
||||
// Orbital superiority at a system: our combat power there exceeds theirs.
|
||||
// Bombardment and invasion both require it.
|
||||
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||||
|
|
@ -1534,8 +1552,8 @@ export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
|||
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
|
||||
// not one empire in eight games was ever eliminated. Bombing also thins the
|
||||
// defenders for a subsequent landing, so the two mechanics work together.
|
||||
export function bombard(rules, state, e, starIdx) {
|
||||
const colony = colonyAt(state, starIdx);
|
||||
export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||
if (!colony || colony.empireIdx === e) return null;
|
||||
if (!atWar(state, e, colony.empireIdx)) return null;
|
||||
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
|
||||
|
|
@ -1585,8 +1603,8 @@ export function bombard(rules, state, e, starIdx) {
|
|||
// both the AI and the human's invade button need the same forecast — the AI
|
||||
// was committing every transport it had to hopeless landings (1971 failures
|
||||
// against 130 successes) purely because nothing told it the odds.
|
||||
export function invasionForecast(rules, state, e, starIdx) {
|
||||
const colony = colonyAt(state, starIdx);
|
||||
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
|
||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||
if (!colony || colony.empireIdx === e) return null;
|
||||
const troops = state.fleets
|
||||
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
||||
|
|
@ -1608,8 +1626,8 @@ export function invasionForecast(rules, state, e, starIdx) {
|
|||
return { troops, defenders, odds, favourable };
|
||||
}
|
||||
|
||||
export function invade(rules, state, e, starIdx) {
|
||||
const colony = colonyAt(state, starIdx);
|
||||
export function invade(rules, state, e, starIdx, orbit = null) {
|
||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||
if (!colony || colony.empireIdx === e) return null;
|
||||
if (!atWar(state, e, colony.empireIdx)) return null;
|
||||
// Holding orbit means orbital SUPERIORITY, not an empty sky.
|
||||
|
|
@ -1987,12 +2005,23 @@ function pickDefense(rules, state, colony) {
|
|||
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
|
||||
}
|
||||
|
||||
// Brian's target fleet mix for Colony Focus: Fleet Production — 4 frigates :
|
||||
// 3 destroyers : 2 cruisers : 1 battleship. Any warship hull missing from
|
||||
// this table (there shouldn't be one — frigate/destroyer/cruiser/battleship
|
||||
// are the only role:'warship' hulls in mastervega-rules.json) falls back to
|
||||
// weight 1.
|
||||
const FLEET_MIX_WEIGHT = { frigate: 4, destroyer: 3, cruiser: 2, battleship: 1 };
|
||||
|
||||
// Independently-implemented twin of VegaAI.js's preferredWarship (same
|
||||
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
|
||||
// cutoff — see the header note above for why this is duplicated rather than
|
||||
// shared), plus a diversity pass: rather than always maxing out the single
|
||||
// best-value hull, it counts what's already docked at this colony's star and
|
||||
// favours whichever warship role is least represented, ties broken by score.
|
||||
// shared), plus a weighted diversity pass: rather than always maxing out the
|
||||
// single best-value hull, it counts what's already docked at this colony's
|
||||
// star and favours whichever warship role is furthest below its target share
|
||||
// of FLEET_MIX_WEIGHT (count/weight, lowest wins — the standard weighted
|
||||
// round-robin comparison), ties broken by score. Starting from an empty
|
||||
// fleet this converges to the 4:3:2:1 ratio after one full 10-ship cycle and
|
||||
// holds it indefinitely, rather than the old equal-count-per-hull split.
|
||||
function pickFleet(rules, state, colony) {
|
||||
const e = colony.empireIdx;
|
||||
const budget = colonyBuildRate(rules, state, colony);
|
||||
|
|
@ -2005,16 +2034,18 @@ function pickFleet(rules, state, colony) {
|
|||
}
|
||||
}
|
||||
let best = null;
|
||||
let bestCount = Infinity;
|
||||
let bestRatio = Infinity;
|
||||
let bestScore = -Infinity;
|
||||
for (const hullId of hullIds) {
|
||||
const d = empireDesign(rules, state, e, hullId);
|
||||
if (d.damage <= 0) continue;
|
||||
if (d.cost > budget * 15) continue;
|
||||
const count = present[hullId] ?? 0;
|
||||
const weight = FLEET_MIX_WEIGHT[hullId] ?? 1;
|
||||
const ratio = count / weight;
|
||||
const score = (d.hp + d.damage * 4) / d.cost;
|
||||
if (count < bestCount || (count === bestCount && score > bestScore)) {
|
||||
best = hullId; bestCount = count; bestScore = score;
|
||||
if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
|
||||
best = hullId; bestRatio = ratio; bestScore = score;
|
||||
}
|
||||
}
|
||||
// Before any weapon tech is known every hull scores damage=0 and `best`
|
||||
|
|
@ -2106,7 +2137,7 @@ export function recommendColonyFocus(rules, state, colony) {
|
|||
return {
|
||||
value: 'fleet', label: 'Fleet Production',
|
||||
reason: "Empire-wide fleet strength is below what's typical this far into the game — "
|
||||
+ 'additional warships would help, favouring whichever type you have fewest of.',
|
||||
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { markNumeral } from './VegaRules.js';
|
||||
import { empireDesign } from './VegaLogic.js';
|
||||
import { refitCost } from './VegaShips.js';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ export const COMPONENT_FIELDS = ['weapons', 'construction', 'forcefields', 'prop
|
|||
|
||||
export const MAX_MARK = 7;
|
||||
|
||||
// Mark is the AVERAGE tier across all five component fields (see markFor
|
||||
// below), with an unresearched field contributing 0 rather than dragging the
|
||||
// average negative — so a real Mark II/III hull with zero weapons research
|
||||
// is a perfectly reachable state if a player (or the AI) simply prioritises
|
||||
// the other four fields. Without this fallback that ship deals literally 0
|
||||
// damage forever, silently: Bombard refuses (bestComponents.allWeapons is
|
||||
// empty), and space combat is a no-op. Strictly worse than the tier-0 Laser
|
||||
// Cannon on every axis, so actually researching lasercannon (or anything
|
||||
// else) is still a real upgrade, not a formality made moot by this floor.
|
||||
const BASELINE_WEAPON = {
|
||||
id: 'baselinemassdriver', name: 'Mass Driver', kind: 'beam',
|
||||
min: 1, max: 2, shots: 1, space: 3, cost: 10,
|
||||
};
|
||||
|
||||
// Highest tier the empire has reached in each field. The chains are linear, so
|
||||
// "best tier" is all we ever need to know.
|
||||
export function fieldTiers(rules, known) {
|
||||
|
|
@ -104,6 +118,11 @@ export function bestComponents(rules, known) {
|
|||
out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space);
|
||||
out.weapon = out.beams[0] ?? out.missiles[0] ?? null;
|
||||
out.allWeapons = weapons;
|
||||
if (!out.allWeapons.length) {
|
||||
out.allWeapons = [BASELINE_WEAPON];
|
||||
out.beams = [BASELINE_WEAPON];
|
||||
out.weapon = BASELINE_WEAPON;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { markNumeral } from './VegaRules.js';
|
||||
import { parsecs } from './VegaGalaxyGen.js';
|
||||
import {
|
||||
|
|
@ -759,11 +759,11 @@ export default class VegaSidePanel {
|
|||
// click, and a fuel-range refusal should stay silent either way.
|
||||
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
|
||||
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
|
||||
{ width: half, height: 48, fontSize: 20 });
|
||||
{ width: half, height: 48, fontSize: 20, scheme: 'green' });
|
||||
if (refusal) accept.setEnabled?.(false);
|
||||
this.body.add(accept);
|
||||
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
|
||||
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost' }));
|
||||
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost', scheme: 'red' }));
|
||||
this.y = btnY + 60;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
|
||||
import { planetFrame, starFrame } from './VegaArt.js';
|
||||
|
|
@ -216,7 +216,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
if (!mine) {
|
||||
// Enemy colony: offer the two things a fleet in orbit can actually do.
|
||||
if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) {
|
||||
const forecast = invasionForecast(rules, state, viewerIdx, starIdx);
|
||||
const forecast = invasionForecast(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||
console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0
|
||||
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
|
||||
: 'No troop transports in orbit.', {
|
||||
|
|
@ -224,12 +224,35 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
wordWrap: { width: panelW },
|
||||
}));
|
||||
y += 44;
|
||||
// Both actions used to fail completely silently — refused by a
|
||||
// precondition (no orbital superiority, no weapons) and an attempted-
|
||||
// but-lost invasion (every troop consumed either way — see invade()
|
||||
// in VegaLogic.js) were visually identical to nothing happening at
|
||||
// all, since the only record was a turn-report event the player
|
||||
// wouldn't see until ending their turn. scene.log() surfaces the
|
||||
// outcome immediately in the same ticker the rest of the HUD uses.
|
||||
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
|
||||
bombard(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
|
||||
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||
if (!result) {
|
||||
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
|
||||
} else if (result.destroyed) {
|
||||
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed, the colony was wiped out.`);
|
||||
} else {
|
||||
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed.`);
|
||||
}
|
||||
onChanged?.(); rebuild();
|
||||
}), { width: 220, height: 42, bg: 0x6b2230 }));
|
||||
if (forecast && forecast.troops > 0) {
|
||||
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
|
||||
invade(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
|
||||
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||
if (!result) {
|
||||
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
|
||||
} else if (result.captured) {
|
||||
scene.log?.(`Invasion of the ${owner.name} colony succeeded — ${result.attackersLeft} marines secured it.`);
|
||||
} else {
|
||||
scene.log?.(`Invasion of the ${owner.name} colony failed — all marines were lost.`);
|
||||
}
|
||||
onChanged?.(); rebuild();
|
||||
}), { width: 220, height: 42 }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,7 +132,36 @@ function describeBuildingDone(rules, state, ev) {
|
|||
function describeShipDone(rules, state, ev) {
|
||||
const h = rules.hulls[ev.hullId];
|
||||
const star = state.galaxy.stars[ev.starIdx];
|
||||
return { headline: `${h.name} completed at ${star.name}.`, lines: [line(h.desc, '#9fb6cc')] };
|
||||
const count = ev.count ?? 1;
|
||||
const headline = count > 1
|
||||
? `${count}× ${h.name} completed at ${star.name}.`
|
||||
: `${h.name} completed at ${star.name}.`;
|
||||
return { headline, lines: [line(h.desc, '#9fb6cc')] };
|
||||
}
|
||||
|
||||
// A well-funded queue can pop several copies of the same hull at the same
|
||||
// colony in a single turn (VegaLogic.js's build loop completes as many
|
||||
// queue entries as the turn's production covers) — each one pushes its own
|
||||
// shipDone event, which used to mean N identical "Frigate completed at
|
||||
// Sol." rows in the New Turn report. Collapsed here into one row per
|
||||
// (empire, colony, hull) with a count, keeping the first event as the
|
||||
// representative so its starIdx/colonyId/hullId stay valid for the report
|
||||
// screen's icon/"View Star System" wiring — only the headline needs the
|
||||
// count. Scoped to shipDone specifically: buildings are unique per colony
|
||||
// (enqueue refuses a duplicate), so buildingDone can never repeat this way.
|
||||
export function groupShipDoneEvents(events) {
|
||||
const out = [];
|
||||
const byKey = new Map();
|
||||
for (const ev of events) {
|
||||
if (ev.type !== 'shipDone') { out.push(ev); continue; }
|
||||
const key = `${ev.empire}|${ev.colonyId}|${ev.hullId}`;
|
||||
const existing = byKey.get(key);
|
||||
if (existing) { existing.count = (existing.count ?? 1) + 1; continue; }
|
||||
const grouped = { ...ev, count: 1 };
|
||||
byKey.set(key, grouped);
|
||||
out.push(grouped);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The recommendations are re-derived here rather than frozen into the event
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
|
|||
import { openShipDetail } from './VegaShipDetail.js';
|
||||
import { buildingFrame } from './VegaArt.js';
|
||||
import { turnToYear } from './VegaRules.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { describeEvent, categoryWeight } from './VegaTurnReport.js';
|
||||
import { Button } from './VegaButton.js';
|
||||
import { describeEvent, categoryWeight, groupShipDoneEvents } from './VegaTurnReport.js';
|
||||
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
|
||||
|
||||
// A colony's own trade minus what its existing buildings already cost to run
|
||||
|
|
@ -50,7 +50,7 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
|
|||
playSound(scene, SFX.VEGA_NEWTURN);
|
||||
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
|
||||
|
||||
const ordered = events
|
||||
const ordered = groupShipDoneEvents(events)
|
||||
.map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) }))
|
||||
.sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
||||
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
|
||||
// and will not interrupt the player for is checkable here.
|
||||
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js';
|
||||
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL, groupShipDoneEvents } from '../src/games/mastervega/VegaTurnReport.js';
|
||||
// Ship media is addressed here and nowhere else, so the key convention is
|
||||
// checkable without a canvas.
|
||||
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
|
||||
|
|
@ -981,6 +981,32 @@ section('4. Ship Marks');
|
|||
const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits);
|
||||
check('star base is immobile', base.immobile && base.speed === 0);
|
||||
|
||||
// A warship hull with literally zero weapon techs known is a real reachable
|
||||
// state (Mark is an average across all five fields, so the other four can
|
||||
// carry it well past Mark I) — without a fallback it deals 0 damage
|
||||
// forever, silently. bestComponents must synthesize a minimal weapon.
|
||||
{
|
||||
const noTech = Ships.bestComponents(RULES, {});
|
||||
check('an empire with zero known techs still has exactly one fallback weapon',
|
||||
noTech.allWeapons.length === 1, `${noTech.allWeapons.length}`);
|
||||
check('the fallback weapon is the documented baseline, not a real tech',
|
||||
noTech.weapon?.id === 'baselinemassdriver', noTech.weapon?.id);
|
||||
const bareFrigate = Ships.designFor(RULES, {}, 'frigate', RULES.species.human.traits);
|
||||
check('a warship with zero known techs still deals damage',
|
||||
bareFrigate.damage > 0, `${bareFrigate.damage}`);
|
||||
check('non-warship hulls stay unarmed even with zero known techs (hull.space gates it)',
|
||||
Ships.designFor(RULES, {}, 'scout', RULES.species.human.traits).damage === 0);
|
||||
// The fallback must be strictly worse than the real tier-0 weapon, so
|
||||
// researching it (or anything else) is still a genuine upgrade.
|
||||
const lasercannonOnly = Ships.bestComponents(RULES, { lasercannon: true });
|
||||
check('the fallback is replaced (not stacked) once a real weapon is known',
|
||||
lasercannonOnly.allWeapons.length === 1 && lasercannonOnly.weapon?.id !== 'baselinemassdriver',
|
||||
JSON.stringify(lasercannonOnly.allWeapons.map((w) => w.id)));
|
||||
const realFrigate = Ships.designFor(RULES, { lasercannon: true }, 'frigate', RULES.species.human.traits);
|
||||
check('the real tier-0 weapon out-damages the fallback',
|
||||
realFrigate.damage > bareFrigate.damage, `${realFrigate.damage} vs ${bareFrigate.damage}`);
|
||||
}
|
||||
|
||||
check('refit costs something and is finite', (() => {
|
||||
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
|
||||
return Number.isFinite(c) && c > 0;
|
||||
|
|
@ -1292,6 +1318,61 @@ section('4c. Population transport');
|
|||
Logic.fleetPower(RULES, st, fleet) === 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A star hosting two colonies (different empires, different orbits) used
|
||||
// to break Bombard/Invade/invasionForecast whenever the non-hostile one
|
||||
// came first in state.colonies: colonyAt() is orbit-blind, so an ally
|
||||
// sharing a system with the real target silently ate every click. Brian
|
||||
// hit this directly — allied Rrashaa on one planet, hostile Lithox on
|
||||
// another, orbital superiority confirmed, both actions refused anyway.
|
||||
{
|
||||
const stM = Logic.createGame(RULES, {
|
||||
sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal',
|
||||
speciesIds: ['human', 'rrashaa', 'kkrix'], humanIndex: 0,
|
||||
});
|
||||
stM.rules = RULES;
|
||||
let starIdx = -1;
|
||||
for (let i = 0; i < stM.galaxy.stars.length; i += 1) {
|
||||
if ((stM.galaxy.stars[i].planets?.length ?? 0) >= 2 && !stM.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
|
||||
}
|
||||
check('the test galaxy has a star with 2+ planets to place two colonies on', starIdx >= 0);
|
||||
if (starIdx >= 0) {
|
||||
// Ally (rrashaa, empire 1) founded FIRST so it lands first in
|
||||
// state.colonies — colonyAt()'s old first-match behaviour would have
|
||||
// picked this one regardless of which orbit was actually being acted
|
||||
// on. Different populations so a forecast computed against the wrong
|
||||
// colony is numerically distinguishable from one computed correctly.
|
||||
const allyColony = Logic.foundColony(RULES, stM, 1, starIdx, 0, 20);
|
||||
const enemyColony = Logic.foundColony(RULES, stM, 2, starIdx, 1, 10);
|
||||
Diplo.declareWar(RULES, stM, 0, 2); // human at war with kkrix only; rrashaa stays unallied-but-not-at-war
|
||||
Logic.addFleet(RULES, stM, 0, starIdx, [
|
||||
{ hullId: 'frigate', count: 3, mark: 1 },
|
||||
{ hullId: 'transport', count: 1, mark: 1 },
|
||||
]);
|
||||
|
||||
check('colonyAt (orbit-blind) resolves to the ally here, confirming the bug precondition is real',
|
||||
Logic.colonyAt(stM, starIdx)?.empireIdx === 1);
|
||||
|
||||
const noOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx);
|
||||
const byEnemyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, enemyColony.orbit);
|
||||
const byAllyOrbit = Logic.invasionForecast(RULES, stM, 0, starIdx, allyColony.orbit);
|
||||
check('invasionForecast without an orbit falls back to the at-war colony, not the ally',
|
||||
noOrbit?.troops > 0 && noOrbit.defenders === byEnemyOrbit?.defenders,
|
||||
`${JSON.stringify(noOrbit)} vs ${JSON.stringify(byEnemyOrbit)}`);
|
||||
check('an explicit orbit actually changes which colony is targeted',
|
||||
byAllyOrbit?.defenders !== byEnemyOrbit?.defenders,
|
||||
`ally ${byAllyOrbit?.defenders} vs enemy ${byEnemyOrbit?.defenders}`);
|
||||
|
||||
check('invade pinned to the ally\'s own orbit correctly refuses (not at war), even though it is a real colony here',
|
||||
Logic.invade(RULES, stM, 0, starIdx, allyColony.orbit) === null);
|
||||
|
||||
// The actual reported bug: no orbit passed at all (VegaAI.js's calling
|
||||
// convention), with the ally still ahead of the enemy in state.colonies.
|
||||
const bombardResult = Logic.bombard(RULES, stM, 0, starIdx);
|
||||
check('bombard without an orbit correctly reaches the hostile colony sharing the star',
|
||||
!!bombardResult, JSON.stringify(bombardResult));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1592,6 +1673,81 @@ section('6. Colony economy');
|
|||
c.focus = 'manual';
|
||||
}
|
||||
|
||||
// --- Fleet Production's weighted 4:3:2:1 mix (frigate:destroyer:cruiser:
|
||||
// battleship). Grants full weapon tech and maxes the ships slider so every
|
||||
// warship hull clears pickFleet's damage>0 and affordability gates —
|
||||
// otherwise this would be testing which hulls got filtered out, not the
|
||||
// mix logic itself.
|
||||
{
|
||||
const c = st.colonies[0];
|
||||
const emp = st.empires[c.empireIdx];
|
||||
const savedKnown = { ...emp.known };
|
||||
const savedSliders = { ...c.sliders };
|
||||
const savedFleets = st.fleets;
|
||||
const savedPop = c.pop;
|
||||
for (const t of RULES.techsByField.weapons) emp.known[t.id] = true;
|
||||
// empireDesign memoizes per-empire designs keyed on emp.techsKnown, which
|
||||
// a direct emp.known mutation (unlike Logic.grantTech) never bumps — null
|
||||
// the cache out by hand or empireDesign silently keeps returning the
|
||||
// pre-grant (no-weapon) design below.
|
||||
emp._designs = null; emp._designsAt = -1;
|
||||
emp._comps = null; emp._compsAt = -1;
|
||||
Logic.setSlider(RULES, st, c, 'ships', 1);
|
||||
// A fresh homeworld's production can't clear battleship's cost*15
|
||||
// affordability gate on its own (that's a real, correct game-balance
|
||||
// fact, not a bug) — overridden here purely so this check can exercise
|
||||
// all four warship hulls at once rather than skipping the top of the mix.
|
||||
c.pop = Logic.colonyMaxPop(RULES, st, c) * 3;
|
||||
const warshipIds = RULES.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
|
||||
const allAffordable = warshipIds.every((id) => {
|
||||
const d = Logic.empireDesign(RULES, st, c.empireIdx, id);
|
||||
return d.damage > 0 && d.cost <= Logic.colonyBuildRate(RULES, st, c) * 15;
|
||||
});
|
||||
|
||||
if (allAffordable) {
|
||||
// Unambiguous minimum: battleship is the only hull under its target
|
||||
// share (ratio 0 vs 1.0 for the other three), so it must win regardless
|
||||
// of score tie-breaking.
|
||||
st.fleets = [];
|
||||
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx,
|
||||
[{ hullId: 'frigate', count: 4, mark: 1 }, { hullId: 'destroyer', count: 3, mark: 1 },
|
||||
{ hullId: 'cruiser', count: 2, mark: 1 }]);
|
||||
c.queue.length = 0;
|
||||
c.focus = 'fleet';
|
||||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||||
check('fleet mix picks the sole hull strictly below its target share',
|
||||
c.queue[0]?.id === 'battleship', c.queue[0]?.id);
|
||||
|
||||
// A full 10-ship cycle from empty must land exactly on 4:3:2:1 — the
|
||||
// weighted round-robin's defining property — no matter how the score
|
||||
// tie-break orders picks along the way.
|
||||
st.fleets = [];
|
||||
const counts = { frigate: 0, destroyer: 0, cruiser: 0, battleship: 0 };
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
c.queue.length = 0;
|
||||
Logic.autoQueueColonies(RULES, st, c.empireIdx);
|
||||
const picked = c.queue[0]?.id;
|
||||
counts[picked] = (counts[picked] ?? 0) + 1;
|
||||
Logic.addFleet(RULES, st, c.empireIdx, c.starIdx, [{ hullId: picked, count: 1, mark: 1 }]);
|
||||
}
|
||||
check('one full mix cycle (10 ships) lands exactly on 4:3:2:1',
|
||||
counts.frigate === 4 && counts.destroyer === 3 && counts.cruiser === 2 && counts.battleship === 1,
|
||||
JSON.stringify(counts));
|
||||
} else {
|
||||
check('fleet mix hull-set is affordable/tech-eligible for the 4:3:2:1 checks above (skipped)',
|
||||
true, 'skipped: not all warship hulls cleared damage/affordability at this budget');
|
||||
}
|
||||
|
||||
st.fleets = savedFleets;
|
||||
emp.known = savedKnown;
|
||||
emp._designs = null; emp._designsAt = -1;
|
||||
emp._comps = null; emp._compsAt = -1;
|
||||
c.sliders = savedSliders;
|
||||
c.pop = savedPop;
|
||||
c.queue.length = 0;
|
||||
c.focus = 'manual';
|
||||
}
|
||||
|
||||
// --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus
|
||||
// / checkAdvisorRecommendations. Uses a throwaway state so nothing here
|
||||
// needs restoring afterward.
|
||||
|
|
@ -1775,6 +1931,40 @@ section('6b. Founding vignette and the turn report');
|
|||
}
|
||||
}
|
||||
|
||||
// Brian's ask: multiple copies of the same hull finishing at the same
|
||||
// colony in one turn used to render as N identical "Frigate completed at
|
||||
// Sol." rows in the New Turn report — groupShipDoneEvents collapses them
|
||||
// into one row with a count.
|
||||
{
|
||||
const stG = Logic.createGame(RULES, {
|
||||
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 7171,
|
||||
speciesIds: ['human', 'kkrix'], humanIndex: 0,
|
||||
});
|
||||
const homeStar = stG.empires[0].homeStar;
|
||||
const mk = (colonyId, hullId, empire = 0) => ({ type: 'shipDone', empire, colonyId, hullId, starIdx: homeStar, turn: 1 });
|
||||
const events = [
|
||||
mk(1, 'frigate'), mk(1, 'frigate'), mk(1, 'frigate'), // 3 frigates, same colony
|
||||
mk(1, 'destroyer'), // a different hull, same colony — stays separate
|
||||
mk(2, 'frigate'), // same hull, DIFFERENT colony — stays separate
|
||||
{ type: 'techDone', empire: 0, techId: 'lasercannon', turn: 1 }, // non-shipDone — passes through untouched
|
||||
];
|
||||
const grouped = groupShipDoneEvents(events);
|
||||
check('groupShipDoneEvents collapses same-colony same-hull duplicates into one row',
|
||||
grouped.length === 4, `${grouped.length}`);
|
||||
const frigateAt1 = grouped.find((ev) => ev.type === 'shipDone' && ev.colonyId === 1 && ev.hullId === 'frigate');
|
||||
check('the collapsed row counts every duplicate', frigateAt1?.count === 3, `${frigateAt1?.count}`);
|
||||
check('a different hull at the same colony is not folded in',
|
||||
grouped.find((ev) => ev.hullId === 'destroyer')?.count === 1);
|
||||
check('the same hull at a different colony is not folded in',
|
||||
grouped.find((ev) => ev.colonyId === 2 && ev.hullId === 'frigate')?.count === 1);
|
||||
check('non-shipDone events pass through untouched',
|
||||
grouped.some((ev) => ev.type === 'techDone'));
|
||||
check('ungrouped shipDone rows (count 1) still read as singular',
|
||||
!describeEvent(RULES, stG, grouped.find((ev) => ev.hullId === 'destroyer')).headline.includes('×'));
|
||||
check('a grouped shipDone row headlines the count',
|
||||
describeEvent(RULES, stG, frigateAt1).headline.startsWith('3× '), describeEvent(RULES, stG, frigateAt1).headline);
|
||||
}
|
||||
|
||||
// Every number the vignette reads out, on a colony one tick old, for every
|
||||
// colonisable world type any species could land on. These are called before
|
||||
// the colony has ever been processed, which is a state no other screen sees.
|
||||
|
|
@ -1875,6 +2065,29 @@ section('7. Diplomacy and the Galactic Council');
|
|||
return !Diplo.canNegotiate(RULES, st, 0, 3);
|
||||
})());
|
||||
|
||||
// A diplomacy-incapable species at war and clearly losing must never queue
|
||||
// a held peace offer for the human — its Seek Audience button is
|
||||
// permanently disabled (VegaScreens.js/MasterOfVegaGame.js), so a queued
|
||||
// offer would leave an un-clearable "seeks an audience" card up with
|
||||
// nothing the human could ever do about it. 300 iterations at the
|
||||
// underlying 12%-per-turn propose chance would almost certainly have
|
||||
// caught at least one leak under the old (unguarded) behaviour.
|
||||
{
|
||||
const stL = Logic.createGame(RULES, {
|
||||
sizeId: 'medium', shapeId: 'elliptical', seed: 55, difficultyId: 'normal',
|
||||
speciesIds: ['human', 'lithox'], humanIndex: 0,
|
||||
});
|
||||
stL.rules = RULES;
|
||||
stL.empires[0].contacted[1] = true;
|
||||
stL.empires[1].contacted[0] = true;
|
||||
stL.empires[0].totalPop = 900;
|
||||
stL.empires[1].totalPop = 50; // lithox is losing badly: power ratio well under 0.55
|
||||
Diplo.declareWar(RULES, stL, 1, 0);
|
||||
for (let i = 0; i < 300; i += 1) Diplo.runDiplomacyTurn(RULES, stL, 1);
|
||||
check('a losing diplomacy-incapable empire never queues a held peace offer',
|
||||
!stL.empires[0].pendingOffers[1]);
|
||||
}
|
||||
|
||||
Diplo.declareWar(RULES, st, 0, 1);
|
||||
check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0));
|
||||
check('being attacked is resented', st.empires[1].attitude[0] < 0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue