diff --git a/assets/gamedata/zuma/level-008.json b/assets/gamedata/zuma/level-008.json index 33be267..0956fac 100644 --- a/assets/gamedata/zuma/level-008.json +++ b/assets/gamedata/zuma/level-008.json @@ -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 + } + ] } diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index c58b95a..1889d0b 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -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); } diff --git a/src/games/mastervega/VegaTechEffects.js b/src/games/mastervega/VegaTechEffects.js new file mode 100644 index 0000000..7b8530d --- /dev/null +++ b/src/games/mastervega/VegaTechEffects.js @@ -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); +} diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 02e8e1b..aebedcc 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -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, + })), }; } diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index d9dee14..b65760d 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -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