fertig-classic-games/src/games/mastervega/VegaStarMap.js

535 lines
20 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 { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js';
import { starFrame } from './VegaArt.js';
const FONT = '"Julius Sans One"';
export const ZOOMS = [0.35, 0.55, 0.85, 1.3, 2.0];
export const DEFAULT_ZOOM_INDEX = 2;
// Semantic-zoom thresholds. Below FAR the map shows territory and empire names
// only; above NEAR it shows per-system detail.
const FAR = 0.5;
const NEAR = 1.2;
// 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.zoomIndex = DEFAULT_ZOOM_INDEX;
this.zoom = ZOOMS[this.zoomIndex];
this.selectedStar = -1;
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);
// --- 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);
this.starLayer = scene.add.container(0, 0);
this.root.add(this.starLayer);
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();
}
// ------------------------------------------------------------------ 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); });
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
// "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.
// 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;
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;
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
for (const key of Object.keys(reach)) {
const star = this.state.galaxy.stars[Number(key)];
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.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];
s.container.setVisible(explored || this.zoom < FAR);
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);
}
}
}
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;
x = a.x + (b.x - a.x) * t;
y = a.y + (b.y - a.y) * t;
}
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.zoom < FAR) {
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.zoom >= NEAR) {
const habitable = 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);
}
}
}
}
// ------------------------------------------------------------- animation
update(_time, delta) {
this.time += delta;
const t = this.time / 1000;
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
clampPan() {
const w = this.worldW * this.zoom;
const h = this.worldH * this.zoom;
const slack = 160;
const minX = Math.min(slack, GAME_WIDTH - w - slack);
const maxX = Math.max(GAME_WIDTH - w - slack, slack);
const minY = Math.min(slack, GAME_HEIGHT - h - slack);
const maxY = Math.max(GAME_HEIGHT - h - slack, slack);
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(minX, maxX), Math.max(minX, maxX));
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(minY, maxY), Math.max(minY, maxY));
}
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) {
const idx = Phaser.Math.Clamp(newIndex, 0, ZOOMS.length - 1);
if (idx === this.zoomIndex) return;
const old = this.zoom;
const next = 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.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) => {
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();
});
scene.input.on('pointerup', () => { dragging = false; });
scene.input.on('wheel', (p, _objs, _dx, dy) => {
if (this.cb.blockWheel?.(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.nebula?.destroy();
this.bgLayer.destroy();
this.starfield.destroy();
this.root.destroy();
}
}