Updated Battle Scanner

This commit is contained in:
Brian Fertig 2026-08-14 11:19:30 -06:00
parent c8e22a51a5
commit cba84dc0b8
5 changed files with 222 additions and 11 deletions

View File

@ -1074,8 +1074,25 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (this.modalOpen || this.busy) return; if (this.modalOpen || this.busy) return;
// Someone else's fleet is not something we can give orders to, but the // Someone else's fleet is not something we can give orders to, but the
// system it is sitting in is — clicking it reads as clicking that system, // system it is sitting in is — clicking it reads as clicking that system,
// which with a fleet in hand is how an attack gets ordered. // which with a fleet in hand is how an attack gets ordered. That takes
// priority over sensor detail below: a fleet already selected for a move
// order must still route through onStarClick's "quote a route" flow
// (same condition onStarClick itself uses), not get swallowed by the
// read-only scan panel.
if (fleet.empireIdx !== this.state.humanIndex) { if (fleet.empireIdx !== this.state.humanIndex) {
const ordering = this.selectedFleet && this.state.fleets.includes(this.selectedFleet)
&& this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== fleet.starIdx;
// Detected on sensors (Battle Scanner and friends' scanRange effect,
// Brian's ask, 2026-08-14): the same fleet panel our own fleets use,
// just read-only (VegaSidePanel.js's buildFleet branches on
// ownership). Outside scan range, or mid-order, a rival fleet marker
// still just reads as "click the system," same as before scanRange
// existed.
if (!ordering && Logic.fleetInScanRange(this.rules, this.state, this.state.humanIndex, fleet)) {
playSound(this, SFX.VEGA_UNIT);
this.panel.showFleet(fleet);
return;
}
if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx); if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx);
return; return;
} }

View File

@ -11,7 +11,9 @@
// Ecology cleans industrial waste; Defence raises planetary defences; Research // Ecology cleans industrial waste; Defence raises planetary defences; Research
// splits into the six tech fields. // splits into the six tech fields.
import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js'; import {
generateGalaxy, parsecs, mulberry32, PARSEC_PX,
} from './VegaGalaxyGen.js';
import { techCost, techCostFactor } from './VegaRules.js'; import { techCost, techCostFactor } from './VegaRules.js';
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js'; import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
// Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only // Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only
@ -359,6 +361,66 @@ export function reachableStars(rules, state, e) {
return out; return out;
} }
// --------------------------------------------------------------------------
// Sensors — battlescanner and friends' scanRange tech effect (Brian's ask,
// 2026-08-14). Unlike reachableStars/fuelRange, this isn't cached per turn:
// it is only ever asked for on a fleet click, not in a hot AI loop, so the
// extra cache bookkeeping (and the extra invalidation sites that would need
// it) buys nothing here.
// A fleet's current position in star-map pixel coordinates: its own star's,
// if docked, or interpolated along its lane if in transit — the same lerp
// VegaStarMap.js's refreshFleets uses to DRAW an in-transit marker, just
// reused here (headless, no Phaser) to MEASURE distance to it instead.
function fleetPosition(state, fleet) {
if (fleet.starIdx >= 0) {
const star = state.galaxy.stars[fleet.starIdx];
return star ? { x: star.x, y: star.y } : null;
}
const a = state.galaxy.stars[fleet.fromStar];
const b = state.galaxy.stars[fleet.toStar];
if (!a || !b) return null;
const t = fleet.total > 0 ? Math.min(1, Math.max(0, fleet.progress / fleet.total)) : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// Every point this empire could currently be detecting FROM: its colonies
// and its own fleets (docked or in transit), unlike reachableStars' sources
// (colonies + starbases only) — a sensor reach is about where your hulls
// actually ARE right now, not where you could send something from.
function scanSources(state, e) {
const sources = [];
for (const c of empireColonies(state, e)) {
const star = state.galaxy.stars[c.starIdx];
if (star) sources.push({ x: star.x, y: star.y });
}
for (const f of empireFleets(state, e)) {
const p = fleetPosition(state, f);
if (p) sources.push(p);
}
return sources;
}
/**
* Whether `viewerE` can currently detect `fleet` (any empire's, including
* its own always true then via a zero-distance source) on sensors: within
* scanRange parsecs of one of the viewer's own colonies or fleets. Zero
* scanRange (no battlescanner-line tech yet) means nothing is ever
* detected this is what turns "reveals enemy fleet composition" from
* flavor text into a real mechanic (see VegaTechEffects.js's scanRange
* formatter, and MasterOfVegaGame.js's onFleetClick / VegaSidePanel.js's
* buildFleet for what unlocks behind it).
*/
export function fleetInScanRange(rules, state, viewerE, fleet) {
const range = empireComponents(rules, state, viewerE).scanRange;
if (range <= 0) return false;
const pos = fleetPosition(state, fleet);
if (!pos) return false;
return scanSources(state, viewerE).some(
(s) => Math.hypot(s.x - pos.x, s.y - pos.y) / PARSEC_PX <= range + 1e-9,
);
}
export function fleetSpeed(rules, state, fleet) { export function fleetSpeed(rules, state, fleet) {
const emp = state.empires[fleet.empireIdx]; const emp = state.empires[fleet.empireIdx];
const spec = rules.species[emp.speciesId]; const spec = rules.species[emp.speciesId];

View File

@ -29,6 +29,7 @@ import {
coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo, coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo,
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta, colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta,
fleetInScanRange,
} from './VegaLogic.js'; } from './VegaLogic.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js'; import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
@ -143,7 +144,11 @@ export default class VegaSidePanel {
// A fresh selection always starts at full strength: any dial-down from a // A fresh selection always starts at full strength: any dial-down from a
// prior selection (this fleet or another) must not carry over here. // prior selection (this fleet or another) must not carry over here.
this.sel = []; this.sel = [];
this.syncSelection(); // The dial-down selector only exists for a fleet we can actually give
// orders to — a rival's fleet reaches here read-only, detected on
// sensors (MasterOfVegaGame.js's onFleetClick), and buildFleet's
// read-only branch never looks at this.sel at all.
if (fleet.empireIdx === this.viewerIdx) this.syncSelection();
this.rebuild(); this.rebuild();
this.open(); this.open();
} }
@ -536,14 +541,20 @@ export default class VegaSidePanel {
const { rules, state } = this; const { rules, state } = this;
const emp = state.empires[fleet.empireIdx]; const emp = state.empires[fleet.empireIdx];
const mine = fleet.empireIdx === this.viewerIdx; const mine = fleet.empireIdx === this.viewerIdx;
// A rival fleet only opens into the read-only detail view once it's
// within sensor range — same gate MasterOfVegaGame.js's onFleetClick
// applies to a click on the map marker, mirrored here so this row is
// just as clickable (Brian's ask, 2026-08-14).
const scanned = mine || (this.viewerIdx >= 0 && fleetInScanRange(rules, state, this.viewerIdx, fleet));
const ships = fleet.ships.reduce((t, s) => t + s.count, 0); const ships = fleet.ships.reduce((t, s) => t + s.count, 0);
const label = `${mine ? 'Your fleet' : emp.name}${ships} ship${ships === 1 ? '' : 's'}` const label = `${mine ? 'Your fleet' : emp.name}${ships} ship${ships === 1 ? '' : 's'}`
+ ` · power ${fleetPower(rules, state, fleet)}`; + ` · power ${fleetPower(rules, state, fleet)}`;
const t = this.line(label, { size: 14, color: mine ? '#9fd8ff' : emp.color, gap: 3 }); const t = this.line(label, { size: 14, color: mine ? '#9fd8ff' : emp.color, gap: 3 });
if (!mine) return; if (!scanned) return;
t.setInteractive({ useHandCursor: true }); t.setInteractive({ useHandCursor: true });
const baseColor = mine ? '#9fd8ff' : emp.color;
t.on('pointerover', () => t.setColor('#ffffff')); t.on('pointerover', () => t.setColor('#ffffff'));
t.on('pointerout', () => t.setColor('#9fd8ff')); t.on('pointerout', () => t.setColor(baseColor));
t.on('pointerup', () => this.cb.onSelectFleet?.(fleet)); t.on('pointerup', () => this.cb.onSelectFleet?.(fleet));
} }
@ -589,6 +600,8 @@ export default class VegaSidePanel {
const fleet = this.fleet; const fleet = this.fleet;
const total = fleet.ships.reduce((t, s) => t + s.count, 0); const total = fleet.ships.reduce((t, s) => t + s.count, 0);
if (fleet.empireIdx !== this.viewerIdx) { this.buildEnemyFleet(total); return; }
if (fleet.starIdx < 0) { if (fleet.starIdx < 0) {
const to = state.galaxy.stars[fleet.toStar]; const to = state.galaxy.stars[fleet.toStar];
const from = state.galaxy.stars[fleet.fromStar]; const from = state.galaxy.stars[fleet.fromStar];
@ -641,6 +654,58 @@ export default class VegaSidePanel {
this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' }); this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' });
} }
/**
* Read-only view of a rival's fleet reachable only once it is within
* sensor range (VegaLogic.js's fleetInScanRange, gated further up the call
* chain in MasterOfVegaGame.js's onFleetClick; Brian's ask, 2026-08-14).
* Reuses the exact same building blocks the player's own fleet view does
* shipLine()/leaderRow()/detailHit() for ship thumbnails, full-stat
* popovers and leader detail, so a rival's ships and captain inspect
* exactly like your own. What's missing is only the /+/ dial-down
* selector and the move-order flow: there is nothing here to command.
*/
buildEnemyFleet(total) {
const { rules, state } = this;
const fleet = this.fleet;
const emp = state.empires[fleet.empireIdx];
if (fleet.starIdx < 0) {
const to = state.galaxy.stars[fleet.toStar];
const from = state.galaxy.stars[fleet.fromStar];
this.setHead(`${emp.name} fleet`, 'Under way — detected');
this.line(`${from?.name ?? '?'}${to?.name ?? '?'}`, { size: 17, color: emp.color });
} else {
const star = state.galaxy.stars[fleet.starIdx];
this.setHead(`${emp.name} fleet`, `In orbit at ${star.name} — detected`);
}
this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`,
{ size: 15, color: '#9fb6cc' });
this.leaderRow();
const mobile = fleet.ships.filter((s) => s.count > 0
&& !empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile);
const garrison = fleet.ships.filter((s) => s.count > 0
&& empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile);
if (mobile.length) {
this.heading('Ships');
for (const s of mobile) {
this.shipLine(fleet.empireIdx, s.hullId, s.mark, `${s.count} × ${this.designName(s.hullId, s.mark)}`);
}
}
if (garrison.length) {
this.heading('Garrison');
for (const s of garrison) {
this.shipLine(fleet.empireIdx, s.hullId, s.mark,
`${s.count} × ${this.designName(s.hullId, s.mark)}`,
{ color: '#8fa8c0', sub: 'Holds this system' });
}
}
this.y = Math.max(this.y + 12, this.h - 138);
this.callout('Detected on sensors — read-only. Click a ship or the captain to inspect it.', '#6fc4ff');
this.action('Close', () => this.cb.onClose?.(), { variant: 'ghost' });
}
/** /**
* A ship stack with a /+ count selector. The name sits on the first line and * A ship stack with a /+ count selector. The name sits on the first line and
* the counter on the second, so the thumbnails can take the left gutter * the counter on the second, so the thumbnails can take the left gutter
@ -732,9 +797,11 @@ export default class VegaSidePanel {
const rowH = Math.max(SIZE, this.y - rowY); const rowH = Math.max(SIZE, this.y - rowY);
this.y = Math.max(this.y, rowY + SIZE + 10); this.y = Math.max(this.y, rowY + SIZE + 10);
const mine = fleet.empireIdx === this.viewerIdx;
const whose = mine ? 'Your fleet' : `${state.empires[fleet.empireIdx].name}'s fleet`;
const where = fleet.starIdx >= 0 const where = fleet.starIdx >= 0
? `Your fleet at ${state.galaxy.stars[fleet.starIdx]?.name ?? '?'}` ? `${whose} at ${state.galaxy.stars[fleet.starIdx]?.name ?? '?'}`
: `Your fleet, en route to ${state.galaxy.stars[fleet.toStar]?.name ?? '?'}`; : `${whose}, en route to ${state.galaxy.stars[fleet.toStar]?.name ?? '?'}`;
const hit = this.scene.add.rectangle(PAD, rowY, COL, rowH, 0xffffff, 0.001) const hit = this.scene.add.rectangle(PAD, rowY, COL, rowH, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true }); .setOrigin(0, 0).setInteractive({ useHandCursor: true });
hit.on('pointerup', () => this.openLeaderDetailFor(leader, where)); hit.on('pointerup', () => this.openLeaderDetailFor(leader, where));

View File

@ -5,9 +5,11 @@
// //
// Every formatter is hand-matched to how the effect is actually consumed // Every formatter is hand-matched to how the effect is actually consumed
// elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a // elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a
// generic key/value dump. `scanRange` is currently an inert stat with no // generic key/value dump. `scanRange` (VegaLogic.js's fleetInScanRange) is
// downstream mechanic wired up yet; its wording stays modest/flavor rather // what unlocks a rival fleet's read-only detail view — MasterOfVegaGame.js's
// than promising a numeric benefit that doesn't exist in the sim. // onFleetClick and VegaSidePanel.js's buildFleet — once one of your own
// colonies or fleets sits within range of it; it does not reveal star
// systems themselves (that's explored[], unrelated).
const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`; const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`;
@ -46,7 +48,8 @@ const FORMATTERS = {
refitCostMult: (rules, v) => `Auto-refit costs reduced by ${pct(v)}.`, refitCostMult: (rules, v) => `Auto-refit costs reduced by ${pct(v)}.`,
repairPerRound: (rules, v) => `Ships self-repair ${Math.round(v * 100)}% of max hull every combat round.`, repairPerRound: (rules, v) => `Ships self-repair ${Math.round(v * 100)}% of max hull every combat round.`,
researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`, researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`,
scanRange: (rules, v) => `+${v} scanner range for detecting distant fleets and systems.`, scanRange: (rules, v) => `+${v} parsec scanner range — enemy fleets within range of one of your `
+ 'colonies or fleets can be inspected in full, ship by ship.',
shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`, shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`,
singularity: (rules) => `A breakthrough in exotic physics — every weapon on this ship warps enemy deflectors, trimming ${rules.combat.singularityShieldPierce ?? 0} point of shielding off every hit it lands.`, singularity: (rules) => `A breakthrough in exotic physics — every weapon on this ship warps enemy deflectors, trimming ${rules.combat.singularityShieldPierce ?? 0} point of shielding off every hit it lands.`,
targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`, targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`,

View File

@ -1474,6 +1474,68 @@ section('4c. Population transport');
} }
} }
// ---------------------------------------------------------------------------
section('4d. Sensor scan range');
// ---------------------------------------------------------------------------
// fleetInScanRange: Battle Scanner and friends' scanRange tech effect,
// wired from flavor text into an actual mechanic (Brian's ask, 2026-08-14).
{
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 44, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
st.rules = RULES;
const home = st.colonies.find((c) => c.empireIdx === 0);
Logic.addFleet(RULES, st, 1, home.starIdx, [{ hullId: 'frigate', count: 2, mark: 1 }]);
const rival = st.fleets.find((f) => f.empireIdx === 1 && f.starIdx === home.starIdx);
check('a rival fleet exists to test against', !!rival);
check('with no scanner tech, even a same-system rival fleet is undetected',
!Logic.fleetInScanRange(RULES, st, 0, rival));
Logic.grantTech(RULES, st, 0, 'battlescanner');
check('battlescanner grants a positive empire scanRange',
Logic.empireComponents(RULES, st, 0).scanRange > 0);
check('with battlescanner known, a same-system rival fleet is detected',
Logic.fleetInScanRange(RULES, st, 0, rival));
// Far enough that even battlescanner's scan bonus can't reach it.
let farIdx = -1;
let farDist = 0;
for (let i = 0; i < st.galaxy.stars.length; i += 1) {
const d = parsecs(st.galaxy, home.starIdx, i);
if (d > farDist) { farDist = d; farIdx = i; }
}
const scanRange = Logic.empireComponents(RULES, st, 0).scanRange;
check('a star far enough away to test the range limit exists', farIdx >= 0 && farDist > scanRange);
if (farIdx >= 0 && farDist > scanRange) {
rival.starIdx = farIdx;
check('a rival fleet beyond scan range is not detected even with battlescanner',
!Logic.fleetInScanRange(RULES, st, 0, rival));
// Scan sources are the viewer's own fleets too, not just colonies —
// sending one of ours next to the rival should light it up.
Logic.addFleet(RULES, st, 0, farIdx, [{ hullId: 'scout', count: 1, mark: 1 }]);
check('a rival fleet becomes detected once one of our OWN fleets sits at its star',
Logic.fleetInScanRange(RULES, st, 0, rival));
// In-transit detection uses the same interpolated position VegaStarMap.js
// draws an en-route marker at, not just docked fleets.
const transit = {
id: st.nextFleetId += 1, empireIdx: 1, starIdx: -1,
fromStar: home.starIdx, toStar: farIdx, progress: 0, total: farDist,
ships: [{ hullId: 'frigate', mark: 1, count: 1 }],
};
st.fleets.push(transit);
check('an in-transit rival fleet just leaving our home star is detected',
Logic.fleetInScanRange(RULES, st, 0, transit));
transit.progress = farDist * 0.5;
check('the same in-transit rival fleet, well down its route, is no longer detected',
!Logic.fleetInScanRange(RULES, st, 0, transit));
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('5. Combat'); section('5. Combat');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------