feat(mastervega): refine star map colony info, tech effects display, and turn report layout

- Add colony info layer showing per-empire colony rosters (name, orbit, pop)
  stacked up-left of colonized stars with colored bars and connector lines
- Change "Range as light" to "Explored as light": reveal only actually
  explored stars (not fuel-reachable) with smaller 1.1pc radius
- Extract VegaTechEffects.js with human-readable descriptions for all
  tech effects, replacing raw key/value dumps in turn report
- Expand building unlock rows to include building descriptions
- Reorganize ship completion rows: hull icon and commander portrait now
  display below the full-width synopsis instead of beside it
- Bump zuma level 8 config to level 21 "Sun Cove" with new path and params
This commit is contained in:
Brian Fertig 2026-08-06 18:33:04 -06:00
parent a7c3ef20e4
commit 0adb689c19
5 changed files with 329 additions and 227 deletions

View File

@ -1,165 +1,106 @@
{
"level": 8,
"name": "Sun Court",
"frog": [
960,
580
],
"colors": 5,
"background": "zuma-background-2",
"seed": 64352,
"starScores": [
1010,
1520,
2020
],
"paths": [
{
"shape": "horseshoe",
"points": [
[
-80,
130
],
[
60,
130
],
[
960,
130
],
[
1191,
154
],
[
1398,
210
],
[
1565,
294
],
[
1680,
399
],
[
1734,
514
],
[
1727,
632
],
[
1659,
742
],
[
1538,
836
],
[
1375,
908
],
[
1185,
951
],
[
983,
964
],
[
785,
946
],
[
607,
900
],
[
462,
831
],
[
361,
743
],
[
311,
646
],
[
313,
547
],
[
366,
454
],
[
464,
374
],
[
598,
313
],
[
755,
275
],
[
923,
263
],
[
1087,
276
],
[
1235,
312
],
[
1356,
368
],
[
1441,
438
],
[
1485,
517
],
[
1485,
598
],
[
1445,
673
],
[
1368,
739
]
],
"tunnels": [
[
1857,
2403
]
],
"quota": 30,
"introBalls": 9,
"pushSpeed": 96,
"powerUpRate": 0.055
}
]
"level": 21,
"name": "Sun Cove",
"frog": [
974,
548
],
"colors": 4,
"seed": 12345,
"starScores": [
640,
960,
1280
],
"paths": [
{
"shape": "custom",
"points": [
[
-34,
173
],
[
609,
138
],
[
1474,
143
],
[
1755,
292
],
[
1786,
592
],
[
1390,
811
],
[
982,
815
],
[
501,
585
]
],
"tunnels": [],
"quota": 24,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
},
{
"shape": "custom",
"points": [
[
-143,
948
],
[
1072,
963
],
[
1634,
911
],
[
1595,
397
],
[
1343,
241
],
[
945,
270
],
[
455,
403
],
[
289,
571
]
],
"tunnels": [
[
1793,
2411
]
],
"quota": 24,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
}
]
}

View File

@ -17,14 +17,27 @@ import { Tooltip } from '../../ui/Tooltip.js';
import { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import {
reachableStars, coloniesAt, empireColonies, fleetEta, habitableForEmpire,
coloniesAt, empireColonies, fleetEta, habitableForEmpire,
} from './VegaLogic.js';
import { starFrame } from './VegaArt.js';
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
import { describeStarTooltip } from './VegaTooltips.js';
import { ORBIT } from './VegaScreens.js';
const FONT = '"Julius Sans One"';
// Per-empire colony roster block drawn up-left of a colonized star. Sizing
// is tuned to clear both the star's own ownership ring and the in-transit
// fleet marker/comet trail, which already occupies the same upper-left
// quadrant at (star.x - 26, star.y - 22).
const COLONY_INFO_GAP_X = 112;
const COLONY_INFO_GAP_Y = 36;
const COLONY_INFO_BAR_H = 12;
const COLONY_INFO_BAR_TEXT_GAP = 8;
const COLONY_INFO_GROUP_GAP = 20;
const COLONY_INFO_MIN_BAR_W = 80;
const COLONY_INFO_TEXT_SIZE = 26;
// The range and territory fields are painted into low-resolution
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe
// single-texture size — and the upscale blur is exactly the soft edge both
@ -101,6 +114,11 @@ export default class VegaStarMap {
.setOrigin(0, 0).setScale(FIELD_DIV);
this.root.add(this.rangeRT);
// Sits behind starLayer (insertion order) so every star sprite paints
// over the connecting lines this layer draws to its own center.
this.colonyInfoLayer = scene.add.container(0, 0);
this.root.add(this.colonyInfoLayer);
this.starLayer = scene.add.container(0, 0);
this.root.add(this.starLayer);
// The selection reticle sits above the stars and below the fleet markers,
@ -218,9 +236,11 @@ export default class VegaStarMap {
// ------------------------------------------------------------- the fields
// "Range as light": the galaxy is covered in darkness, and everything inside
// fuel range is erased back out of it. Every propulsion tech literally lights
// up more of the map, which is the clearest progression signal a 4X can give.
// "Explored as light": the galaxy is covered in darkness, and each star the
// player has actually explored (colonised, or had a fleet visit) gets a soft
// patch erased around it. Deliberately NOT fuel range — reachability is left
// for the player to work out by trial and error rather than telegraphed on
// the map.
// A scratch image reused for every stamp. RenderTexture.erase()/draw() honour
// a game object's scale and tint, but NOT a bare texture key's — a key is
// always stamped at its native 256px. Everything here needs a radius that
@ -244,14 +264,14 @@ export default class VegaStarMap {
const emp = this.state.empires[this.viewerIdx];
if (!emp) return;
const reach = reachableStars(this.rules, this.state, this.viewerIdx);
// One soft disc per reachable star, wide enough that neighbouring discs
// overlap — otherwise the lit region reads as a string of beads instead of
// one continuous sphere of influence.
const radius = PARSEC_PX * 2.6;
// One soft disc per explored star — sized to clear just that star's own
// neighbourhood, not to bridge gaps into a "sphere of influence" the way
// the old fuel-range version did (that would re-imply territory the
// player hasn't actually scouted).
const radius = PARSEC_PX * 1.1;
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
for (const key of Object.keys(reach)) {
const star = this.state.galaxy.stars[Number(key)];
for (const star of this.state.galaxy.stars) {
if (!emp.explored[star.idx]) continue;
rt.erase(img, star.x / FIELD_DIV, star.y / FIELD_DIV);
}
}
@ -283,6 +303,7 @@ export default class VegaStarMap {
this.redrawRange();
this.redrawTerritory();
this.refreshStars();
this.refreshColonyInfo();
this.refreshFleets();
this.refreshLabels();
}
@ -312,6 +333,64 @@ export default class VegaStarMap {
}
}
/**
* Per-empire colony roster stacked up-left of a colonized star: a thick
* owner-colored bar over a "name (pop. N)" list, one group per empire that
* holds a colony at the star, each with its own line running to the star's
* center. Only shown at the closest zoom tier same density tier as the
* "N worlds · M habitable" subtext to keep the mid/far zoom map legible.
*/
refreshColonyInfo() {
this.colonyInfoLayer.removeAll(true);
if (this.isFarZoom || !this.isNearZoom) return;
const { state } = this;
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
const textSize = Math.round(COLONY_INFO_TEXT_SIZE / Math.max(0.7, this.zoom));
for (const s of this.starSprites) {
if (viewer && !viewer.explored[s.star.idx]) continue;
const cols = coloniesAt(state, s.star.idx);
if (!cols.length) continue;
const groups = [];
for (const emp of state.empires) {
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
if (!own.length) continue;
const text = this.scene.add.text(0, 0, own.map((c) => (
`${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1} (pop. ${c.pop.toFixed(1)})`
)).join('\n'), {
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
}).setOrigin(1, 0);
groups.push({ emp, text });
}
if (!groups.length) continue;
const barWidth = Math.max(COLONY_INFO_MIN_BAR_W, ...groups.map((g) => g.text.width));
const blockRightX = s.star.x - COLONY_INFO_GAP_X;
const totalHeight = groups.reduce((h, g) => (
h + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP + g.text.height
), 0) + COLONY_INFO_GROUP_GAP * (groups.length - 1);
let y = s.star.y - COLONY_INFO_GAP_Y - totalHeight;
const lines = this.scene.add.graphics();
this.colonyInfoLayer.add(lines);
for (const g of groups) {
const colour = Phaser.Display.Color.HexStringToColor(g.emp.color).color;
const bar = this.scene.add.rectangle(
blockRightX - barWidth / 2, y + COLONY_INFO_BAR_H / 2, barWidth, COLONY_INFO_BAR_H, colour,
).setStrokeStyle(2, 0x000000, 0.85);
this.colonyInfoLayer.add(bar);
lines.lineStyle(3, colour, 0.7);
lines.lineBetween(blockRightX, y + COLONY_INFO_BAR_H / 2, s.star.x, s.star.y);
g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP);
this.colonyInfoLayer.add(g.text);
y += COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP + g.text.height + COLONY_INFO_GROUP_GAP;
}
}
}
refreshFleets() {
this.fleetLayer.removeAll(true);
const { state, rules } = this;
@ -664,6 +743,7 @@ export default class VegaStarMap {
this.clampPan();
this.refreshLabels();
this.refreshStars();
this.refreshColonyInfo();
this.cb.onZoom?.(next);
}

View File

@ -0,0 +1,63 @@
// Master of Vega — human-readable descriptions for `tech.effects` values.
// Headless, no Phaser: kept separate from VegaTurnReport.js so any other
// screen (a future tech-tree view, say) can reuse the same wording without
// pulling in turn-report-specific code.
//
// Every formatter is hand-matched to how the effect is actually consumed
// elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a
// generic key/value dump. A few effects (cloaked, scanRange, singularity)
// are currently inert stats with no downstream mechanic wired up yet; their
// wording stays modest/flavor rather than promising a numeric benefit that
// doesn't exist in the sim.
const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`;
function describeWeapon(w) {
const perRound = w.shots > 1 ? `, ${w.shots} shots per round` : '';
const pierce = w.shieldPierce
? `, pierces ${w.shieldPierce} point${w.shieldPierce === 1 ? '' : 's'} of enemy shields` : '';
const cracker = w.planetCracker ? ' — powerful enough to crack a planet apart' : '';
return `New ${w.kind} weapon available: ${w.name}, ${w.min}-${w.max} damage per shot${perRound}${pierce}${cracker}.`;
}
function hostilityWorlds(rules, tier) {
const names = rules.planetTypes
.filter((t) => t.colonizable && t.hostility === tier)
.map((t) => t.name);
return names.length ? `${names.join('/')}-class` : `hostility ${tier}`;
}
const FORMATTERS = {
armor: (rules, v) => `Hull armor upgraded to ${v.name} plating — hull hit points ×${v.hpMult} (+${pct(v.hpMult)}).`,
cloaked: () => 'Ships are fitted with a stealth field, cloaking them from enemy sensors.',
colonizeHostility: (rules, v) => `Colonists can now settle ${hostilityWorlds(rules, v)} worlds.`,
counterEspionage: (rules, v) => `+${v} counter-espionage — makes it harder for enemy agents to steal your technology.`,
engine: (rules, v) => `Engines upgraded to ${v.name} Drive (speed ${v.speed}).`,
espionage: (rules, v) => `+${v} espionage — improves your odds of stealing enemy technology.`,
factoryCostMult: (rules, v) => `Factories cost ${pct(v)} less to build.`,
fuelRange: (rules, v) => `Fuel range extended to ${v} parsecs — fleets can be ordered further from friendly colonies and starbases.`,
groundAttack: (rules, v) => `+${v} ground attack — improves the odds of a successful invasion.`,
groundDefense: (rules, v) => `+${v} ground defense — colonies field more defenders when invaded.`,
initiative: (rules, v) => `+${v} initiative — fleets choose their engagement range before slower opponents each combat round.`,
maxPopBonus: (rules, v) => `+${v} maximum population per colony.`,
planetaryShield: (rules, v) => (
`+${v} planetary shield — cuts population lost to bombardment by about ${Math.round((1 - 1 / (1 + v * 0.15)) * 100)}%.`
),
redirectInFlight: () => 'Fleets already in transit can be redirected to a new destination.',
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.`,
researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`,
scanRange: (rules, v) => `+${v} scanner range for detecting distant fleets and systems.`,
shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`,
singularity: () => "A breakthrough in exotic physics — the foundation for the galaxy's ultimate weapon.",
targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`,
wasteMult: (rules, v) => `Industrial waste reduced by ${pct(v)}.`,
weapon: (rules, v) => describeWeapon(v),
};
/** Turns a tech's `effects` object into an array of full sentences, one per effect. */
export function describeTechEffects(rules, effects) {
return Object.entries(effects ?? {})
.map(([key, value]) => FORMATTERS[key]?.(rules, value))
.filter(Boolean);
}

View File

@ -5,6 +5,7 @@
// MasterOfVegaGame.js/VegaScreens.js keeps those files from ballooning.
import { habitableForEmpire } from './VegaLogic.js';
import { describeTechEffects } from './VegaTechEffects.js';
// Events worth interrupting the player for. Everything else (refit,
// spyCaught, techStolen, invasionFailed, leaderHired, victory — which
@ -99,9 +100,8 @@ function describeTechDone(rules, state, ev) {
const emp = state.empires[ev.empire];
const gate = rules.techGates[ev.techId] ?? { buildings: [], prereqOf: [] };
const lines = [line(tech.desc, '#9fb6cc')];
const effects = Object.entries(tech.effects ?? {});
if (effects.length) {
lines.push(line(`Effects: ${effects.map(([k, v]) => `${k} ${v > 0 ? '+' : ''}${v}`).join(', ')}`, '#7fd8a0'));
for (const effectLine of describeTechEffects(rules, tech.effects)) {
lines.push(line(effectLine, '#7fd8a0'));
}
const newBuildingIds = gate.buildings.filter((id) => rules.buildings[id]);
const newTechs = gate.prereqOf
@ -115,7 +115,9 @@ function describeTechDone(rules, state, ev) {
return {
headline: `Research completed: ${tech.name}.`,
lines,
buildingUnlocks: newBuildingIds.map((id) => ({ id, name: rules.buildings[id].name })),
buildingUnlocks: newBuildingIds.map((id) => ({
id, name: rules.buildings[id].name, desc: rules.buildings[id].desc,
})),
};
}

View File

@ -37,7 +37,6 @@ const ROW_BG = 0x142238;
// fallback ladder), a building just its one icon.
const MEDIA_SIZE = 72;
const HULL_ICON_SIZE = 52;
const SHIP_MEDIA_W = 150;
const BUILDING_MEDIA_W = 90;
// Event types whose row gets a "View Star System" shortcut — anything
@ -95,7 +94,10 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
const linesTop = cy;
const isBuilding = ev.type === 'buildingDone';
const isShip = ev.type === 'shipDone';
const mediaW = isShip ? SHIP_MEDIA_W : (isBuilding ? BUILDING_MEDIA_W : 0);
// A ship's media block now sits below its text (full-width synopsis);
// only a building's icon still sits beside its text, so only that
// case needs to reserve wrap width off to the side.
const mediaW = isBuilding ? BUILDING_MEDIA_W : 0;
for (const ln of desc.lines) {
const t = scene.add.text(16, cy, ln.text, {
fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc',
@ -116,7 +118,15 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
fontFamily: FONT, fontSize: '15px', color: '#ffd88a',
});
col.content.add(label);
cy += label.height + 8;
cy += label.height + 6;
if (bu.desc) {
const buildingDesc = scene.add.text(16, cy, bu.desc, {
fontFamily: FONT, fontSize: '13px', color: '#9fb6cc', wordWrap: { width: w - 32 },
});
col.content.add(buildingDesc);
cy += buildingDesc.height + 8;
}
const status = scene.add.text(16, cy + 34, '', {
fontFamily: FONT, fontSize: '13px', color: '#7fd8a0', wordWrap: { width: w - 32 },
@ -145,55 +155,61 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
}
}
// The built item's picture, right-aligned against the synopsis text
// it sits beside. Reserving mediaW off the wrap width above (rather
// than overlaying it) keeps long descriptions from running under it.
if (isBuilding || isShip) {
// A building's picture sits right-aligned beside its synopsis text
// (mediaW reserved off the wrap width above keeps long descriptions
// from running under it). A ship's hull icon + commander portrait sit
// below its (full-width) synopsis text instead, left-aligned to match
// the text above them.
if (isBuilding) {
cy = Math.max(cy, linesTop + MEDIA_SIZE);
const midY = linesTop + MEDIA_SIZE / 2;
if (isBuilding) {
const icon = scene.add.image(
w - 8 - MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId),
).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE);
col.content.add(icon);
} else {
const emp = state.empires[ev.empire];
const portrait = makeCommanderPortrait(
scene, rules, scene.art, emp.speciesId, ev.hullId,
w - 8 - MEDIA_SIZE / 2, midY, MEDIA_SIZE,
);
col.content.add(portrait);
const hullLeft = w - 8 - MEDIA_SIZE - 8 - HULL_ICON_SIZE;
const hullIcon = makeShipIcon(
scene, rules, scene.art, emp.speciesId, ev.hullId,
hullLeft + HULL_ICON_SIZE / 2, midY, HULL_ICON_SIZE,
);
col.content.add(hullIcon);
const icon = scene.add.image(
w - 8 - MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId),
).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE);
col.content.add(icon);
} else if (isShip) {
const mediaTop = cy;
const midY = mediaTop + MEDIA_SIZE / 2;
const emp = state.empires[ev.empire];
const hullLeft = 16;
const hullIcon = makeShipIcon(
scene, rules, scene.art, emp.speciesId, ev.hullId,
hullLeft + HULL_ICON_SIZE / 2, midY, HULL_ICON_SIZE,
);
col.content.add(hullIcon);
const portraitLeft = hullLeft + HULL_ICON_SIZE + 8;
const portrait = makeCommanderPortrait(
scene, rules, scene.art, emp.speciesId, ev.hullId,
portraitLeft + MEDIA_SIZE / 2, midY, MEDIA_SIZE,
);
col.content.add(portrait);
// Same pop-over the fleet side panel opens on a ship row click
// (VegaSidePanel.detailHit → openShipDetail). It draws its own
// veil above this modal's depth and doesn't go through
// scene.openModal, so it's safe to open without closing the
// report first. The clip already playing here pauses while the
// pop-over runs its own copy at full size, same as the side
// panel's pool does — otherwise the same clip decodes twice.
const isVideoPortrait = portrait.type === 'Video';
const hit = scene.add.rectangle(hullLeft, linesTop, w - 8 - hullLeft, MEDIA_SIZE, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
hit.on('pointerup', (p) => {
if (!col.contains(p) || shipDetail) return;
if (isVideoPortrait && portrait.scene) portrait.pause?.();
shipDetail = openShipDetail(
scene, rules, state, scene.art,
{ empireIdx: ev.empire, hullId: ev.hullId },
() => {
shipDetail = null;
if (isVideoPortrait && portrait.scene) portrait.resume?.();
},
);
});
col.content.add(hit);
}
// Same pop-over the fleet side panel opens on a ship row click
// (VegaSidePanel.detailHit → openShipDetail). It draws its own
// veil above this modal's depth and doesn't go through
// scene.openModal, so it's safe to open without closing the
// report first. The clip already playing here pauses while the
// pop-over runs its own copy at full size, same as the side
// panel's pool does — otherwise the same clip decodes twice.
const isVideoPortrait = portrait.type === 'Video';
const hit = scene.add.rectangle(
hullLeft, mediaTop, portraitLeft + MEDIA_SIZE - hullLeft, MEDIA_SIZE, 0xffffff, 0.001,
).setOrigin(0, 0).setInteractive({ useHandCursor: true });
hit.on('pointerup', (p) => {
if (!col.contains(p) || shipDetail) return;
if (isVideoPortrait && portrait.scene) portrait.pause?.();
shipDetail = openShipDetail(
scene, rules, state, scene.art,
{ empireIdx: ev.empire, hullId: ev.hullId },
() => {
shipDetail = null;
if (isVideoPortrait && portrait.scene) portrait.resume?.();
},
);
});
col.content.add(hit);
cy = mediaTop + MEDIA_SIZE + 10;
}
// Discovery and production rows get a shortcut straight to the