// Master of Vega — the star map. // // Layer order (each one a SEPARATE root container, because a Phaser Container // renders its children in insertion order and ignores their depth — putting // these in one container and setting .depth would silently do nothing): // // nebula -> parallax starfield -> territory field -> starlanes // -> range darkness -> stars -> fleets -> labels // // Everything from `territory` down lives inside `this.root`, which is the one // object that gets scaled and moved for pan and zoom. The nebula and the // parallax layers are screen-space and move at their own rates. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Tooltip } from '../../ui/Tooltip.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { makeNebula } from './VegaNebula.js'; import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js'; import { coloniesAt, empireColonies, fleetEta, habitableForEmpire, } from './VegaLogic.js'; import { starFrame } from './VegaArt.js'; import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js'; import { delaunayTriangulate, triangulationEdges } from './VegaDelaunay.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; // Docked-fleet marker fan: index 0 keeps the original single-fleet anchor // (up-right of the star). Later indices zigzag further up-right so several // fleets parked at one star (different empires, or a garrison split from // the mobile stack) never sit exactly on top of one another and each stays // its own independently clickable hit target. const FLEET_STAGGER_DX = 26; const FLEET_STAGGER_DY0 = -22; const FLEET_STAGGER_ZIGZAG = 16; const FLEET_STAGGER_STEP = 28; // Extra pan room beyond the galaxy's own edge, in screen pixels, so any star // can be dragged clear of screen-space chrome that docks over the map — // chiefly the 400px-wide right-hand command panel (VegaSidePanel.js) — at // every zoom level, including the fully-zoomed-out one where the map exactly // fills the viewport and previously had zero pan room at all. const PAN_SLACK = 420; // Range-darkness corridors between the player's own colonies (see // redrawRange) use a band slightly narrower than the per-star clear discs. const RANGE_CORRIDOR_FACTOR = 0.8; // 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 // effects want anyway, so the low resolution is a feature, not a compromise. const FIELD_DIV = 6; function ensureSoftDisc(scene, key, size) { if (scene.textures.exists(key)) return key; const tex = scene.textures.createCanvas(key, size, size); const ctx = tex.getContext(); const g = ctx.createRadialGradient(size / 2, size / 2, size * 0.04, size / 2, size / 2, size / 2); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(0.55, 'rgba(255,255,255,0.85)'); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); tex.refresh(); return key; } export default class VegaStarMap { constructor(scene, rules, state, artKeys, callbacks = {}) { this.scene = scene; this.rules = rules; this.state = state; this.art = artKeys; this.cb = callbacks; this.viewerIdx = state.humanIndex; this.zooms = buildZoomLadder(state.galaxy.width, state.galaxy.height); this.zoomIndex = Math.min(DEFAULT_ZOOM_INDEX, this.zooms.length - 1); this.zoom = this.zooms[this.zoomIndex]; this.selectedStar = -1; this.selectedFleet = null; this.routePreview = null; this.hoverStar = -1; this.rangeDirty = true; this.territoryDirty = true; this.time = 0; const galaxy = state.galaxy; this.worldW = galaxy.width; this.worldH = galaxy.height; ensureSoftDisc(scene, 'vega-soft-disc', 256); this.tooltip = new Tooltip(scene, { depth: 70 }); // --- screen-space backdrop this.bgLayer = scene.add.container(0, 0).setDepth(0); this.nebula = makeNebula(scene, this.bgLayer, { seed: galaxy.seed, shapeId: galaxy.shapeId, density: 1, }); this.starfield = scene.add.container(0, 0).setDepth(1); this.parallax = []; this.buildParallax(galaxy.seed); // --- world-space this.root = scene.add.container(0, 0).setDepth(2); this.fieldW = Math.max(2, Math.ceil(this.worldW / FIELD_DIV)); this.fieldH = Math.max(2, Math.ceil(this.worldH / FIELD_DIV)); this.territoryRT = scene.add.renderTexture(0, 0, this.fieldW, this.fieldH) .setOrigin(0, 0).setScale(FIELD_DIV).setAlpha(0.5) .setBlendMode(Phaser.BlendModes.ADD); this.root.add(this.territoryRT); this.laneGfx = scene.add.graphics(); this.root.add(this.laneGfx); // Range darkness has to reach past the galaxy's own edge to cover // PAN_SLACK too, or panning into that slack would reveal bare starfield // instead of the same dark fill. Sized for the worst case — the bottom // zoom rung, where a screen pixel of slack costs the most world pixels — // so every other rung ends up with a little extra coverage, never a gap. this.rangeMargin = Math.ceil(PAN_SLACK / this.zooms[0]); const rangeFieldW = this.fieldW + Math.ceil((this.rangeMargin * 2) / FIELD_DIV); const rangeFieldH = this.fieldH + Math.ceil((this.rangeMargin * 2) / FIELD_DIV); this.rangeRT = scene.add.renderTexture(-this.rangeMargin, -this.rangeMargin, rangeFieldW, rangeFieldH) .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, // so a fleet parked over its own star is never hidden by its own highlight. this.selGfx = scene.add.graphics(); this.root.add(this.selGfx); this.routeGfx = scene.add.graphics(); this.root.add(this.routeGfx); this.fleetLayer = scene.add.container(0, 0); this.root.add(this.fleetLayer); this.labelLayer = scene.add.container(0, 0); this.root.add(this.labelLayer); this.buildStars(); this.drawLanes(); this.refresh(); this.centerOn(state.galaxy.homeIdx[Math.max(0, this.viewerIdx)] ?? 0); this.bindInput(); } // Semantic zoom is keyed to the LADDER INDEX, not an absolute scale. The same // scale means different things in different galaxies now that the ladder is // built per galaxy — 0.74 is fully zoomed out on a small map and mid-range on // a huge one — so an absolute threshold would put the two in different modes // while showing the same amount of sky. get isFarZoom() { return this.zoomIndex === 0 && this.zooms.length > 1; } get isNearZoom() { return this.zoomIndex >= this.zooms.length - 2; } // ------------------------------------------------------------------ setup buildParallax(seed) { const rnd = mulberry32(seed * 7919 + 13); // Four layers at different rates. The nearest layer is sparse and bright, // the far ones dense and dim, which is what sells depth on a pan. const layers = [ { count: 260, rate: 0.08, size: 1.0, alpha: 0.35 }, { count: 180, rate: 0.16, size: 1.4, alpha: 0.5 }, { count: 110, rate: 0.28, size: 1.9, alpha: 0.7 }, { count: 45, rate: 0.44, size: 2.6, alpha: 0.9 }, ]; for (const spec of layers) { const g = this.scene.add.graphics(); const stars = []; for (let i = 0; i < spec.count; i += 1) { const x = rnd() * GAME_WIDTH * 1.6 - GAME_WIDTH * 0.3; const y = rnd() * GAME_HEIGHT * 1.6 - GAME_HEIGHT * 0.3; const tone = 0.65 + rnd() * 0.35; stars.push({ x, y, tone }); } for (const s of stars) { const c = Math.round(255 * s.tone); g.fillStyle((c << 16) | (c << 8) | 255, spec.alpha); g.fillCircle(s.x, s.y, spec.size); } this.starfield.add(g); this.parallax.push({ g, rate: spec.rate, baseX: 0, baseY: 0 }); } } buildStars() { const { rules, state, scene } = this; this.starSprites = []; for (const star of state.galaxy.stars) { const cls = rules.starClasses[star.classId]; const container = scene.add.container(star.x, star.y); const frame = Math.max(0, starFrame(rules, star.classId)); const body = scene.add.image(0, 0, this.art.stars, frame); const scale = (cls.radius * 5.5) / 192; body.setScale(scale); body.setBlendMode(cls.special === 'blackhole' ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.ADD); container.add(body); // Binary companion and pulsar beam are animated in update(). let companion = null; if (cls.special === 'binary') { companion = scene.add.image(0, 0, this.art.stars, frame); companion.setScale(scale * 0.55).setBlendMode(Phaser.BlendModes.ADD); container.add(companion); } // Ownership ring, drawn only when the system is settled. const ring = scene.add.graphics(); container.add(ring); // Generous hit area — these are small targets at low zoom. const hit = scene.add.circle(0, 0, 30, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); hit.on('pointerover', () => { this.hoverStar = star.idx; this.cb.onStarHover?.(star.idx); }); hit.on('pointerout', () => { if (this.hoverStar === star.idx) this.hoverStar = -1; this.cb.onStarHover?.(-1); }); hit.on('pointerup', (p) => { if (!this.dragged) this.cb.onStarClick?.(star.idx, p); }); this.tooltip.attachTo(hit, () => { const viewer = this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null; return describeStarTooltip(this.rules, this.state, viewer, star.idx); }); container.add(hit); this.starLayer.add(container); this.starSprites.push({ star, container, body, companion, ring, cls, phase: star.companionAngle }); } } drawLanes() { const g = this.laneGfx; g.clear(); for (const lane of this.state.galaxy.lanes) { const a = this.state.galaxy.stars[lane.a]; const b = this.state.galaxy.stars[lane.b]; // Long lanes fade out — they are geometrically real but visually noise. const alpha = Math.max(0.05, 0.3 - lane.parsecs * 0.012); g.lineStyle(1.5, 0x4b6fa8, alpha); g.lineBetween(a.x, a.y, b.x, b.y); } } // ------------------------------------------------------------- the fields // "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 // varies, so it all goes through this one object. stamp(scale, tint = null, alpha = 1) { if (!this._stamp) { this._stamp = this.scene.make.image({ key: 'vega-soft-disc', add: false }).setOrigin(0.5, 0.5); } this._stamp.setScale(scale).setAlpha(alpha); if (tint === null) this._stamp.clearTint(); else this._stamp.setTint(tint); return this._stamp; } redrawRange() { this.rangeDirty = false; const rt = this.rangeRT; rt.clear(); if (this.viewerIdx < 0) return; // observer mode sees the whole galaxy rt.fill(0x04050b, 0.8); const emp = this.state.empires[this.viewerIdx]; if (!emp) return; // One soft disc per explored star — sized to clear just that star's own // neighbourhood, not to bridge gaps into a blanket "sphere of influence" // the way the old fuel-range version did (that would re-imply territory // the player hasn't actually scouted). The one deliberate exception is // below: corridors between the player's OWN colonies specifically. const radius = PARSEC_PX * 1.1; const img = this.stamp((radius * 2) / 256 / FIELD_DIV); for (const star of this.state.galaxy.stars) { if (!emp.explored[star.idx]) continue; // Stamp coordinates are local to the RT's own buffer, whose origin sits // rangeMargin before world (0,0) now — offset to compensate. rt.erase(img, (star.x + this.rangeMargin) / FIELD_DIV, (star.y + this.rangeMargin) / FIELD_DIV); } // "Controlled space": a Delaunay triangulation of the player's own // colonized stars ONLY (never AI, never mere exploration) clears a // corridor between geometric neighbors, plus the interior of any // resulting triangular face. Recomputed from scratch every redraw off // current colony ownership, so a colony that's lost simply drops out — // its corridors and any triangles through it vanish with it, with no // separate bookkeeping needed. const colonized = [...new Set(empireColonies(this.state, emp.idx).map((c) => c.starIdx))] .map((idx) => this.state.galaxy.stars[idx]); if (colonized.length === 2) { this.stampRangeCorridor(rt, colonized[0], colonized[1], radius); } else if (colonized.length >= 3) { const tris = delaunayTriangulate(colonized.map((s) => ({ x: s.x, y: s.y }))); for (const [ai, bi, ci] of tris) { this.fillRangeTriangle(rt, colonized[ai], colonized[bi], colonized[ci]); } for (const [ai, bi] of triangulationEdges(tris)) { this.stampRangeCorridor(rt, colonized[ai], colonized[bi], radius); } } } /** Soft-edged erased band between two colonies, at RANGE_CORRIDOR_FACTOR * of the star discs' own radius — a brush stroke of overlapping stamps of * the same soft-disc image the star discs use, for a consistent look. */ stampRangeCorridor(rt, a, b, starRadius) { const corridorRadius = starRadius * RANGE_CORRIDOR_FACTOR; const img = this.stamp((corridorRadius * 2) / 256 / FIELD_DIV); const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy); const steps = Math.max(1, Math.ceil(len / corridorRadius)); for (let i = 0; i <= steps; i += 1) { const t = i / steps; const x = a.x + dx * t + this.rangeMargin; const y = a.y + dy * t + this.rangeMargin; rt.erase(img, x / FIELD_DIV, y / FIELD_DIV); } } /** Hard-edged erased fill for one triangular face of the colony mesh. The * overlapping soft corridor stamps along its own edges (drawn separately) * feather the otherwise-sharp boundary. */ fillRangeTriangle(rt, a, b, c) { if (!this._rangeTriGfx) this._rangeTriGfx = this.scene.make.graphics({ add: false }); const g = this._rangeTriGfx.clear().fillStyle(0xffffff, 1); const m = this.rangeMargin; g.fillTriangle( (a.x + m) / FIELD_DIV, (a.y + m) / FIELD_DIV, (b.x + m) / FIELD_DIV, (b.y + m) / FIELD_DIV, (c.x + m) / FIELD_DIV, (c.y + m) / FIELD_DIV, ); rt.erase(g, 0, 0); } // Soft empire colour fields instead of hard borders. redrawTerritory() { this.territoryDirty = false; const rt = this.territoryRT; rt.clear(); const viewer = this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null; for (const colony of this.state.colonies) { const emp = this.state.empires[colony.empireIdx]; if (!emp) continue; if (viewer && !viewer.explored[colony.starIdx]) continue; const star = this.state.galaxy.stars[colony.starIdx]; // Bigger colonies project further, so a homeworld anchors a region and an // outpost only tints its own system. const spread = Phaser.Math.Clamp(0.7 + colony.pop / 90, 0.7, 2.4); const radius = PARSEC_PX * 1.8 * spread; const img = this.stamp((radius * 2) / 256 / FIELD_DIV, Phaser.Display.Color.HexStringToColor(emp.color).color, 0.85); rt.draw(img, star.x / FIELD_DIV, star.y / FIELD_DIV); } } // -------------------------------------------------------------- rendering refresh() { this.redrawRange(); this.redrawTerritory(); this.refreshStars(); this.refreshColonyInfo(); this.refreshFleets(); this.refreshLabels(); } refreshStars() { const { state, rules } = this; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null; for (const s of this.starSprites) { const explored = !viewer || viewer.explored[s.star.idx]; // Unexplored stars stay visible (dimmed) at every zoom level so there is // always something to hover/click for the UNEXPLORED tooltip and a scout // target — only their name, ring and system contents are hidden. s.container.setVisible(true); s.body.setAlpha(explored ? 1 : 0.25); const cols = coloniesAt(state, s.star.idx); s.ring.clear(); if (!cols.length || !explored) continue; const owner = state.empires[cols[0].empireIdx]; const colour = Phaser.Display.Color.HexStringToColor(owner.color).color; s.ring.lineStyle(2.5, colour, 0.95); s.ring.strokeCircle(0, 0, s.cls.radius * 2.2 + 8); if (cols.some((c) => c.capital)) { s.ring.lineStyle(1.5, colour, 0.6); s.ring.strokeCircle(0, 0, s.cls.radius * 2.2 + 14); } } } /** * 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. Full text only at the closest zoom tier — same density tier as * the "N worlds · M habitable" subtext — to keep the mid-zoom map legible; * the two tiers below that still show the bar+line (ownership at a glance) * with the text dropped, and the whole block disappears at the farthest * tier. */ refreshColonyInfo() { this.colonyInfoLayer.removeAll(true); if (this.isFarZoom) return; const { state } = this; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null; const showText = this.isNearZoom; 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 = showText ? this.scene.add.text(0, 0, own.map((c) => ( `${c.name ?? `${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`} (pop. ${Math.round(c.pop)})` )).join('\n'), { fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2, }).setOrigin(1, 0) : null; groups.push({ emp, text }); } if (!groups.length) continue; const barWidth = Math.max(COLONY_INFO_MIN_BAR_W, ...groups.map((g) => (g.text ? g.text.width : 0))); const blockRightX = s.star.x - COLONY_INFO_GAP_X; const totalHeight = groups.reduce((h, g) => ( h + COLONY_INFO_BAR_H + (g.text ? COLONY_INFO_BAR_TEXT_GAP + g.text.height : 0) ), 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); let groupHeight = COLONY_INFO_BAR_H; if (g.text) { g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP); this.colonyInfoLayer.add(g.text); groupHeight += COLONY_INFO_BAR_TEXT_GAP + g.text.height; } y += groupHeight + COLONY_INFO_GROUP_GAP; } } } refreshFleets() { this.fleetLayer.removeAll(true); const { state, rules } = this; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null; this.fleetMarkers = []; // Fleets docked at a star (starIdx >= 0) are grouped per star and sorted // by empire so a given species' icon(s) land adjacent in the fan — see // refreshFleets' position math below. const dockedByStar = new Map(); for (const fleet of state.fleets) { if (fleet.starIdx < 0) continue; if (!dockedByStar.has(fleet.starIdx)) dockedByStar.set(fleet.starIdx, []); dockedByStar.get(fleet.starIdx).push(fleet); } for (const group of dockedByStar.values()) group.sort((a, b) => a.empireIdx - b.empireIdx); for (const fleet of state.fleets) { const emp = state.empires[fleet.empireIdx]; if (!emp) continue; const own = fleet.empireIdx === this.viewerIdx; let x; let y; if (fleet.starIdx >= 0) { const star = state.galaxy.stars[fleet.starIdx]; if (viewer && !own && !viewer.explored[fleet.starIdx]) continue; const slot = dockedByStar.get(fleet.starIdx).indexOf(fleet); x = star.x + FLEET_STAGGER_DX + (slot % 2 === 1 ? FLEET_STAGGER_ZIGZAG : 0); y = star.y + FLEET_STAGGER_DY0 - slot * FLEET_STAGGER_STEP; } else { const a = state.galaxy.stars[fleet.fromStar]; const b = state.galaxy.stars[fleet.toStar]; if (!a || !b) continue; if (viewer && !own && !viewer.explored[fleet.toStar]) continue; const t = fleet.total > 0 ? Phaser.Math.Clamp(fleet.progress / fleet.total, 0, 1) : 0; // Offset up-left of the lane so a fleet just leaving its origin star // doesn't sit directly on top of it and block the star's own click target. x = a.x + (b.x - a.x) * t - 26; y = a.y + (b.y - a.y) * t - 22; } const colour = Phaser.Display.Color.HexStringToColor(emp.color).color; const c = this.scene.add.container(x, y); // Fleets in transit render as a comet: a bright head with a trail back // along the lane, plus tick marks for the turns still to run. if (fleet.starIdx < 0) { const a = state.galaxy.stars[fleet.fromStar]; const b = state.galaxy.stars[fleet.toStar]; const ang = Math.atan2(b.y - a.y, b.x - a.x); const trail = this.scene.add.graphics(); for (let i = 1; i <= 8; i += 1) { trail.fillStyle(colour, 0.42 * (1 - i / 9)); trail.fillCircle(-Math.cos(ang) * i * 7, -Math.sin(ang) * i * 7, 5 - i * 0.45); } c.add(trail); if (own) { const eta = fleetEta(rules, state, fleet); const label = this.scene.add.text(0, -20, `${Number.isFinite(eta) ? eta : '—'}`, { fontFamily: FONT, fontSize: '15px', color: '#cfe3ff', }).setOrigin(0.5); c.add(label); } } const head = this.scene.add.graphics(); head.fillStyle(colour, 1); head.fillCircle(0, 0, 6); head.lineStyle(1.5, 0xffffff, 0.75); head.strokeCircle(0, 0, 6); c.add(head); const hit = this.scene.add.circle(0, 0, 16, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); hit.on('pointerup', () => { if (!this.dragged) this.cb.onFleetClick?.(fleet); }); hit.on('pointerover', () => this.cb.onFleetHover?.(fleet)); hit.on('pointerout', () => this.cb.onFleetHover?.(null)); c.add(hit); this.fleetLayer.add(c); this.fleetMarkers.push({ fleet, container: c }); } } refreshLabels() { this.labelLayer.removeAll(true); const { state } = this; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null; // At the widest zoom the map shows empire names over their territory // instead of a fog of unreadable star labels. if (this.isFarZoom) { for (const emp of state.empires) { if (!emp.alive) continue; const cols = empireColonies(state, emp.idx); if (!cols.length) continue; if (viewer && emp.idx !== viewer.idx && !viewer.contacted[emp.idx]) continue; const cx = cols.reduce((t, c) => t + state.galaxy.stars[c.starIdx].x, 0) / cols.length; const cy = cols.reduce((t, c) => t + state.galaxy.stars[c.starIdx].y, 0) / cols.length; const t = this.scene.add.text(cx, cy, emp.name.toUpperCase(), { fontFamily: FONT, fontSize: `${Math.round(46 / this.zoom)}px`, color: emp.color, }).setOrigin(0.5).setAlpha(0.55); this.labelLayer.add(t); } return; } for (const s of this.starSprites) { if (viewer && !viewer.explored[s.star.idx]) continue; const cols = coloniesAt(state, s.star.idx); const owner = cols.length ? state.empires[cols[0].empireIdx] : null; const label = this.scene.add.text(s.star.x, s.star.y + s.cls.radius * 2.2 + 14, s.star.name, { fontFamily: FONT, fontSize: `${Math.round(17 / Math.max(0.7, this.zoom))}px`, color: owner ? owner.color : '#9fb3cc', }).setOrigin(0.5, 0); this.labelLayer.add(label); // Closest zoom adds the system's contents. if (this.isNearZoom) { // "Habitable" here means settleable at the viewer's CURRENT // planetology, not just the planet type's static ceiling — a Radiated // world is colonizable in principle but still shows as 0/1 until tech // catches up, matching what the system view's colonize button says. const habitable = viewer ? s.star.planets.filter((p, orbit) => habitableForEmpire(this.rules, state, viewer.idx, s.star.idx, orbit)).length : s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length; if (s.star.planets.length) { const sub = this.scene.add.text( s.star.x, s.star.y + s.cls.radius * 2.2 + 32, `${s.star.planets.length} worlds · ${habitable} habitable`, { fontFamily: FONT, fontSize: '13px', color: '#6f8199' }, ).setOrigin(0.5, 0); this.labelLayer.add(sub); } } } } // ---------------------------------------------------------- selection /** Ring the system the command panel is currently talking about. -1 clears. */ setSelectedStar(idx) { this.selectedStar = idx ?? -1; this.selectedFleet = null; this.clearRoutePreview(); this.drawSelection(); } /** * Ring a specific fleet marker instead of the star it happens to be at. * A fleet that is already underway (or has a standing order) gets the same * flowing route drawn to its live destination — tracked by reference so it * keeps pace with the fleet as it moves, not a snapshot of where it was. */ setSelectedFleet(fleet) { this.selectedFleet = fleet ?? null; this.selectedStar = -1; if (fleet && fleet.toStar >= 0) { this.routePreview = { fleet }; } else { this.routePreview = null; } this.routeGfx.clear(); this.drawSelection(); if (this.routePreview) this.drawRoutePreview(); } /** Flowing dashes from `fromIdx` to `toIdx` — the route a pending order will fly. */ setRoutePreview(fromIdx, toIdx) { if (fromIdx == null || toIdx == null || fromIdx < 0 || toIdx < 0) { this.clearRoutePreview(); return; } this.routePreview = { from: fromIdx, to: toIdx }; this.drawRoutePreview(); } clearRoutePreview() { this.routePreview = null; this.routeGfx.clear(); } /** Resolve the preview's start/end points — either a fixed star pair (a * pending click-to-order) or a live fleet reference (an underway fleet * tracked to its destination as it flies). */ resolveRouteEndpoints() { const rp = this.routePreview; if (!rp) return null; if (rp.fleet) { const f = rp.fleet; if (!this.state.fleets.includes(f) || f.toStar < 0) return null; const dest = this.state.galaxy.stars[f.toStar]; if (!dest) return null; let ax; let ay; if (f.starIdx >= 0) { // Read the marker's actual on-screen position rather than // recomputing the offset — docked fleets are staggered per-star, so // the offset depends on how many other fleets share that star. const marker = this.fleetMarkers?.find((m) => m.fleet === f); if (!marker) return null; ax = marker.container.x; ay = marker.container.y; } else { const from = this.state.galaxy.stars[f.fromStar]; if (!from) return null; const t = f.total > 0 ? Phaser.Math.Clamp(f.progress / f.total, 0, 1) : 0; ax = from.x + (dest.x - from.x) * t - 26; ay = from.y + (dest.y - from.y) * t - 22; } return { ax, ay, bx: dest.x, by: dest.y, }; } const from = this.state.galaxy.stars[rp.from]; const to = this.state.galaxy.stars[rp.to]; if (!from || !to) return null; return { ax: from.x, ay: from.y, bx: to.x, by: to.y, }; } drawRoutePreview() { const g = this.routeGfx; g.clear(); const pts = this.resolveRouteEndpoints(); if (!pts) return; const a = { x: pts.ax, y: pts.ay }; const b = { x: pts.bx, y: pts.by }; const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy); if (len < 1) return; const ux = dx / len; const uy = dy / len; // Marching dashes: a fixed dash/gap pattern whose phase slides toward the // destination, reading as a current flowing along the route rather than a // static line — the "ship will fly this way" cue the ring around the // target star used to give less directly. const dash = 22; const gap = 16; const period = dash + gap; const speed = 90; // d advances with time (not -phase) so the dashes drift from `a` toward // `b` — matching the ship's actual direction of travel, not the reverse. const phase = ((this.time / 1000) * speed) % period; g.lineStyle(3, 0x9fd8ff, 0.85); for (let d = phase - period; d < len; d += period) { const s = Math.max(d, 0); const e = Math.min(d + dash, len); if (e <= s) continue; g.beginPath(); g.moveTo(a.x + ux * s, a.y + uy * s); g.lineTo(a.x + ux * e, a.y + uy * e); g.strokePath(); } // Arrowhead planted on the destination star, pointing along the route. const ang = Math.atan2(dy, dx); const ah = 11; const tip = { x: b.x - ux * 6, y: b.y - uy * 6 }; g.fillStyle(0x9fd8ff, 0.9); g.beginPath(); g.moveTo(tip.x, tip.y); g.lineTo(tip.x - Math.cos(ang - 2.6) * ah, tip.y - Math.sin(ang - 2.6) * ah); g.lineTo(tip.x - Math.cos(ang + 2.6) * ah, tip.y - Math.sin(ang + 2.6) * ah); g.closePath(); g.fillPath(); } drawSelection() { const g = this.selGfx; g.clear(); let cx; let cy; let r; if (this.selectedFleet) { const marker = this.fleetMarkers?.find((m) => m.fleet === this.selectedFleet); if (!marker) return; cx = marker.container.x; cy = marker.container.y; r = 20; } else { const s = this.starSprites?.[this.selectedStar]; if (!s) return; cx = s.star.x; cy = s.star.y; r = s.cls.radius * 2.2 + 20; } // Four arcs rather than a circle: a broken reticle stays legible on top of // an ownership ring, which a second full circle would not. const spin = (this.time / 1000) * 0.5; g.lineStyle(2, 0x9fd8ff, 0.95); for (let i = 0; i < 4; i += 1) { const a = spin + (i * Math.PI) / 2; g.beginPath(); g.arc(cx, cy, r, a, a + 0.7); g.strokePath(); } } // ------------------------------------------------------------- animation update(_time, delta) { this.time += delta; const t = this.time / 1000; if (this.selectedStar >= 0 || this.selectedFleet) this.drawSelection(); if (this.routePreview) this.drawRoutePreview(); for (const s of this.starSprites) { if (s.cls.special === 'pulsar') { s.body.setRotation(t * 1.4); } else if (s.cls.special === 'binary' && s.companion) { const a = s.phase + t * 0.7; const r = s.cls.radius * 1.9; s.companion.setPosition(Math.cos(a) * r, Math.sin(a) * r * 0.55); s.body.setPosition(-Math.cos(a) * r * 0.35, -Math.sin(a) * r * 0.2); } else if (s.cls.special === 'blackhole') { s.body.setRotation(-t * 0.35); } else { // A slow breath so the map is never completely static. s.body.setAlpha(0.88 + Math.sin(t * 1.2 + s.star.idx) * 0.12); } } // Parallax follows the camera, each layer at its own rate. for (const layer of this.parallax) { layer.g.setPosition(this.root.x * layer.rate, this.root.y * layer.rate); } } // ---------------------------------------------------------------- camera // Keep the viewport close to the galaxy, with PAN_SLACK of give on every // side. An earlier version clamped with zero slack, which was airtight but // meant the map's edge sat pinned exactly at the screen edge at the bottom // of the zoom ladder — right where the command panel docks — so an edge // star there could never be dragged out from under the panel. PAN_SLACK // fixes that while still bounding how much empty space beyond the galaxy // can ever be revealed. clampPan() { const w = this.worldW * this.zoom; const h = this.worldH * this.zoom; this.root.x = w + PAN_SLACK * 2 >= GAME_WIDTH ? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w - PAN_SLACK, PAN_SLACK) : (GAME_WIDTH - w) / 2; this.root.y = h + PAN_SLACK * 2 >= GAME_HEIGHT ? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h - PAN_SLACK, PAN_SLACK) : (GAME_HEIGHT - h) / 2; } applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) { const idx = Phaser.Math.Clamp(newIndex, 0, this.zooms.length - 1); if (idx === this.zoomIndex) return; playSound(this.scene, idx > this.zoomIndex ? SFX.VEGA_ZOOMIN : SFX.VEGA_ZOOMOUT); const old = this.zoom; const next = this.zooms[idx]; // Keep whatever is under the cursor under the cursor. const worldX = (focusX - this.root.x) / old; const worldY = (focusY - this.root.y) / old; this.zoomIndex = idx; this.zoom = next; this.root.setScale(next); this.root.x = focusX - worldX * next; this.root.y = focusY - worldY * next; this.clampPan(); this.refreshLabels(); this.refreshStars(); this.refreshColonyInfo(); this.cb.onZoom?.(next); } centerOn(starIdx) { const star = this.state.galaxy.stars[starIdx]; if (!star) return; this.root.setScale(this.zoom); this.root.x = GAME_WIDTH / 2 - star.x * this.zoom; this.root.y = GAME_HEIGHT / 2 - star.y * this.zoom; this.clampPan(); } panToStar(starIdx, duration = 420) { const star = this.state.galaxy.stars[starIdx]; if (!star) return; const targetX = GAME_WIDTH / 2 - star.x * this.zoom; const targetY = GAME_HEIGHT / 2 - star.y * this.zoom; this.scene.tweens.add({ targets: this.root, x: targetX, y: targetY, duration, ease: 'Sine.easeInOut', onComplete: () => this.clampPan(), }); } bindInput() { const scene = this.scene; this.dragged = false; let dragging = false; let startX = 0; let startY = 0; let originX = 0; let originY = 0; scene.input.on('pointerdown', (p) => { // A drag that starts on the command panel scrolls nothing — otherwise // dialling a ship count would pan the galaxy out from under the panel. if (this.cb.blockPointer?.(p)) return; dragging = true; this.dragged = false; startX = p.x; startY = p.y; originX = this.root.x; originY = this.root.y; }); scene.input.on('pointermove', (p) => { if (!dragging) return; const dx = p.x - startX; const dy = p.y - startY; // An 8px threshold so a slightly shaky click still selects a star. if (Math.abs(dx) > 8 || Math.abs(dy) > 8) this.dragged = true; if (!this.dragged) return; this.root.x = originX + dx; this.root.y = originY + dy; this.clampPan(); }); // Phaser emits the game objects' own pointerup handlers first and then this // one, with everything under the cursor in `objs` — so an empty list is a // genuine click on the void, which is how the panel gets dismissed. scene.input.on('pointerup', (p, objs) => { dragging = false; if (!this.dragged && (!objs || objs.length === 0) && !this.cb.blockPointer?.(p)) { this.cb.onEmptyClick?.(p); } }); scene.input.on('wheel', (p, _objs, _dx, dy) => { if (this.cb.blockWheel?.(p) || this.cb.blockPointer?.(p)) return; this.applyZoom(this.zoomIndex + (dy > 0 ? -1 : 1), p.x, p.y); }); } setViewer(idx) { this.viewerIdx = idx; this.rangeDirty = true; this.refresh(); } destroy() { this.tooltip.destroy(); this.nebula?.destroy(); this.bgLayer.destroy(); this.starfield.destroy(); this.root.destroy(); } }