828 lines
32 KiB
JavaScript
828 lines
32 KiB
JavaScript
// 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 { 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 { 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
|
|
// 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);
|
|
|
|
this.rangeRT = scene.add.renderTexture(0, 0, this.fieldW, this.fieldH)
|
|
.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 "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 star of this.state.galaxy.stars) {
|
|
if (!emp.explored[star.idx]) continue;
|
|
rt.erase(img, star.x / FIELD_DIV, star.y / FIELD_DIV);
|
|
}
|
|
}
|
|
|
|
// 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. 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;
|
|
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
|
|
this.fleetMarkers = [];
|
|
|
|
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;
|
|
x = star.x + 26;
|
|
y = star.y - 22;
|
|
} 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) {
|
|
const star = this.state.galaxy.stars[f.starIdx];
|
|
if (!star) return null;
|
|
// Match the fleet marker's own offset from the star it sits at.
|
|
ax = star.x + 26;
|
|
ay = star.y - 22;
|
|
} 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 inside the galaxy. The old version allowed 160px of slack
|
|
// on every side, which let the player drag empty space into view even at a
|
|
// zoom that covered the screen — the same complaint as zooming out too far,
|
|
// just reached a different way.
|
|
clampPan() {
|
|
const w = this.worldW * this.zoom;
|
|
const h = this.worldH * this.zoom;
|
|
// At the bottom of the ladder w/h equal the viewport, so both bounds
|
|
// collapse to a single value and the map sits locked and centred.
|
|
this.root.x = w >= GAME_WIDTH
|
|
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w, 0)
|
|
: (GAME_WIDTH - w) / 2;
|
|
this.root.y = h >= GAME_HEIGHT
|
|
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h, 0)
|
|
: (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;
|
|
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();
|
|
}
|
|
}
|