fertig-classic-games/src/games/totalannihilation/TAWorldView.js

681 lines
26 KiB
JavaScript

// Total Annihilation — world renderer.
//
// Camera model: a REAL Phaser world camera plus a second screen-space UI camera, the
// MiniMotorwaysGame.js:213-227 pattern. A real camera buys frustum culling, setBounds,
// zoomTo and — decisively for an RTS — pointer.worldX/worldY for click-to-order.
//
// Terrain bakes into chunked RenderTextures (CivilizationMapView's forEachChunk model). A
// large map is 128 tiles = 8192px, well past a safe single GPU texture, so it tiles into
// 1024px chunks created LAZILY as the camera reaches them — 36 chunks eagerly stamped would
// cost several seconds and ~150MB at mission start.
//
// Units are plain Images, never Containers: at 300+ units the Container overhead is real.
// Selection rings, health bars and build ghosts all share ONE Graphics each.
import { ensureSheets, sheetFrameSize } from './TAArt.js';
import { colorInt } from './TAFx.js';
const CHUNK_PX = 1024;
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
const CHUNK_IDLE_MS = 30000; // reclaim chunks the camera hasn't looked at in a while
const MAX_QUEUE_LINES = 24; // selected units whose order queue is drawn
/** Waypoint colour per order type, so a queue reads at a glance. */
const ORDER_COLORS = {
move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff,
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff, repair: 0x8affd0,
};
export const DEPTHS = {
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
};
export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) {
this.scene = scene;
this.rules = rules;
this.art = art;
this.state = state;
this.playerArmy = playerArmy;
this.ts = state.tileSize;
this.worldRoot = scene.add.container(0, 0);
this.uiRoot = scene.add.container(0, 0);
const { keys, procedural } = ensureSheets(scene, rules, art);
this.sheetKeys = keys;
this.proceduralSheets = procedural;
// Per-army resolved sheet keys, so a unit def's `sheetSlot` renders correctly for any army.
this.armySheets = rules.armies.map((a) => ({
unitSheet: keys[a.unitSheet],
structureSheet: keys[a.structureSheet],
unitFrame: sheetFrameSize(art, a.unitSheet),
structureFrame: sheetFrameSize(art, a.structureSheet),
color: a.colorInt,
}));
const themeName = state.theme;
const theme = art.themes?.[themeName];
this.terrainKey = keys[theme?.sheet] ?? keys[Object.keys(art.sheets)[0]];
this.themePalette = theme?.palette ?? {};
this._setupCameras();
this._setupTerrain();
this._setupFog();
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection);
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
this._addWorld(this.gSelection);
this._addWorld(this.gOrders);
this._addWorld(this.gBars);
this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret }
this.selection = new Set();
this.queueSelection = null; // { unitId, index } — a highlighted entry in a drawn order queue
this.placement = null; // { def, tx, ty, legal }
this.showAllBars = false;
this._stamp = scene.make.image({ x: 0, y: 0, key: this.terrainKey, add: false }).setOrigin(0, 0);
}
// -------------------------------------------------------------------------
// Cameras
// -------------------------------------------------------------------------
_setupCameras() {
const { scene, state } = this;
const cam = scene.cameras.main;
const margin = this.ts * 3;
cam.setBounds(-margin, -margin, state.worldW + margin * 2, state.worldH + margin * 2);
cam.setBackgroundColor(0x0b0e12);
this.zoomIdx = 2;
cam.setZoom(ZOOMS[this.zoomIdx]);
this.uiCam = scene.cameras.add(0, 0, scene.scale.width, scene.scale.height);
this.uiCam.ignore(this.worldRoot);
cam.ignore(this.uiRoot);
}
/**
* Add a child to the world container.
*
* Phaser Containers render their children in INSERTION order and never sort by depth on
* their own — `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth
* here is inert until `sort('depth')` runs, which is why the fog image (built in the
* constructor, long before the terrain chunks it must cover) was drawn underneath the map
* and the whole battlefield was visible. Anything added mid-frame — a lazily painted
* terrain chunk, a new unit sprite — re-dirties the order.
*/
_addWorld(obj) {
this.worldRoot.add(obj);
this._layerDirty = true;
return obj;
}
/** Keep screen-space extras (music controls, fullscreen button) off the world camera. */
ignoreOnWorldCam(objs) {
if (!objs?.length) return;
this.scene.cameras.main.ignore(objs);
}
panBy(dx, dy) {
const cam = this.scene.cameras.main;
cam.setScroll(cam.scrollX + dx / cam.zoom, cam.scrollY + dy / cam.zoom);
}
centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); }
/**
* Zoom one step, keeping whatever the cursor is over pinned under the cursor.
*
* The scroll is solved directly from the camera's screen->world mapping rather than by
* diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts
* `camera.matrix`, and that matrix is only rebuilt in preRender — so the second call reads
* the OLD zoom while the camera already holds the new one, and the correction it yields is
* wrong. Solving it makes the anchor exact and independent of when in the frame this runs.
*
* At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way
* rather than letting the view slide off the world.
*/
zoomBy(dir, focusX, focusY) {
const cam = this.scene.cameras.main;
const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir));
if (next === this.zoomIdx) return;
const anchor = this.worldPoint(focusX, focusY);
this.zoomIdx = next;
const z = ZOOMS[next];
cam.setZoom(z);
cam.setScroll(
anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z,
anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z,
);
}
get zoom() { return this.scene.cameras.main.zoom; }
/**
* Screen -> world, from the camera's live scroll and zoom.
*
* Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during
* preRender, so it reports a stale transform for any call made after the camera has moved
* earlier in the same frame — which is every click, since camera panning runs first in
* update(). This mirrors Camera.preRender's own maths:
* worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom
*/
worldPoint(screenX, screenY) {
const cam = this.scene.cameras.main;
const z = cam.zoom;
return {
x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z,
y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z,
};
}
// -------------------------------------------------------------------------
// Terrain (lazy chunked RenderTextures)
// -------------------------------------------------------------------------
_setupTerrain() {
const { state } = this;
this.chunkCols = Math.ceil(state.worldW / CHUNK_PX);
this.chunkRows = Math.ceil(state.worldH / CHUNK_PX);
this.chunks = new Map(); // "cx,cy" -> { rt, ox, oy, w, h, touched }
}
_paintChunk(cx, cy) {
const key = `${cx},${cy}`;
let chunk = this.chunks.get(key);
if (chunk) { chunk.touched = this.scene.time.now; return chunk; }
const { state, rules, ts } = this;
const ox = cx * CHUNK_PX, oy = cy * CHUNK_PX;
const w = Math.min(CHUNK_PX, state.worldW - ox);
const h = Math.min(CHUNK_PX, state.worldH - oy);
const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain);
this._addWorld(rt);
const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts);
const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts);
const img = this._stamp;
img.setTexture(this.terrainKey);
img.setDisplaySize(ts, ts);
rt.beginDraw();
for (let ty = r0; ty < r1 && ty < state.h; ty++) {
for (let tx = t0; tx < t1 && tx < state.w; tx++) {
const terr = rules.terrain[state.terrain[ty * state.w + tx]];
// Break up flat ground with the two alternate frames, deterministically per tile.
let frame = terr.frame;
if (terr.id === 'ground') {
const hsh = (tx * 73856093) ^ (ty * 19349663);
const pick = (hsh >>> 3) % 5;
if (pick === 1) frame = this.art.terrainFrames?.groundAlt1 ?? frame;
else if (pick === 2) frame = this.art.terrainFrames?.groundAlt2 ?? frame;
}
img.setFrame(frame);
rt.batchDraw(img, tx * ts - ox, ty * ts - oy);
}
}
rt.endDraw();
chunk = { rt, ox, oy, w, h, touched: this.scene.time.now };
this.chunks.set(key, chunk);
return chunk;
}
/** Create chunks the camera can see; retire ones it hasn't looked at for a while. */
_updateChunks() {
const cam = this.scene.cameras.main;
const view = cam.worldView;
const c0 = Math.max(0, Math.floor((view.x - CHUNK_PX * 0.25) / CHUNK_PX));
const c1 = Math.min(this.chunkCols - 1, Math.floor((view.right + CHUNK_PX * 0.25) / CHUNK_PX));
const r0 = Math.max(0, Math.floor((view.y - CHUNK_PX * 0.25) / CHUNK_PX));
const r1 = Math.min(this.chunkRows - 1, Math.floor((view.bottom + CHUNK_PX * 0.25) / CHUNK_PX));
for (let cy = r0; cy <= r1; cy++) {
for (let cx = c0; cx <= c1; cx++) this._paintChunk(cx, cy);
}
const now = this.scene.time.now;
for (const [key, chunk] of this.chunks) {
if (now - chunk.touched < CHUNK_IDLE_MS) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
/** Repaint the chunks overlapping a world rect — used when terrain changes. */
repaintArea(x0, y0, x1, y1) {
for (const [key, chunk] of this.chunks) {
if (x1 < chunk.ox || x0 > chunk.ox + chunk.w) continue;
if (y1 < chunk.oy || y0 > chunk.oy + chunk.h) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
// -------------------------------------------------------------------------
// Fog of war
// -------------------------------------------------------------------------
_setupFog() {
const { state, scene } = this;
const key = 'ta-fog-canvas';
if (scene.textures.exists(key)) scene.textures.remove(key);
this.fogTex = scene.textures.createCanvas(key, state.visW, state.visH);
this.fogCtx = this.fogTex.getContext();
const cell = this.ts * 2;
this.fogImg = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(DEPTHS.fog);
this.fogImg.setDisplaySize(state.visW * cell, state.visH * cell);
// Linear filtering upscales the coarse grid into a soft gradient for the cost of a few
// hundred pixels of texture — far cheaper than a per-tile fog RenderTexture.
this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR
this._addWorld(this.fogImg);
this.fogDirty = true;
}
setFogEnabled(on) {
this.fogEnabled = on;
this.fogImg.setVisible(on);
}
_redrawFog() {
const { state } = this;
const army = state.armies[this.playerArmy];
if (!army) return;
const ctx = this.fogCtx;
ctx.clearRect(0, 0, state.visW, state.visH);
const img = ctx.createImageData(state.visW, state.visH);
const d = img.data;
for (let i = 0; i < state.visW * state.visH; i++) {
const vis = army.visible[i], exp = army.explored[i];
const a = vis ? 0 : (exp ? 140 : 255);
d[i * 4] = 4; d[i * 4 + 1] = 6; d[i * 4 + 2] = 10; d[i * 4 + 3] = a;
}
ctx.putImageData(img, 0, 0);
this.fogTex.refresh();
}
/** Is this entity currently drawable for the viewing player? */
visibleToPlayer(e) {
if (!this.fogEnabled) return true;
if (e.army === this.playerArmy) return true;
const army = this.state.armies[this.playerArmy];
if (!army) return true;
const cell = this.ts * 2;
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
if (x < 0 || y < 0 || x >= this.state.visW || y >= this.state.visH) return false;
return army.visible[y * this.state.visW + x] === 1;
}
// -------------------------------------------------------------------------
// Sprites
// -------------------------------------------------------------------------
_ensureSprite(e) {
let s = this.sprites.get(e.id);
if (s) return s;
const rules = this.rules;
const def = rules.defById[e.defId];
const sheets = this.armySheets[e.army];
const key = sheets[def.sheetSlot];
const frameSize = def.sheetSlot === 'unitSheet' ? sheets.unitFrame : sheets.structureFrame;
const img = this.scene.add.image(e.x, e.y, key, def.frame);
if (def.isBuilding) {
img.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
} else {
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
}
this._addWorld(img);
// Buildings get a second, initially-invisible image showing the FINISHED sprite,
// drawn just behind `img` (which carries the wireframe while under construction) so
// the two can crossfade as the site progresses. See the site/progress block in render().
let final = null;
if (def.isBuilding) {
final = this.scene.add.image(e.x, e.y, key, def.frame);
final.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
final.setAlpha(0);
this._addWorld(final);
}
let turret = null;
if (!def.isBuilding && def.turretFrame != null) {
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this._addWorld(turret);
}
s = { img, turret, final, defId: e.defId };
this.sprites.set(e.id, s);
return s;
}
_releaseSprite(id) {
const s = this.sprites.get(id);
if (!s) return;
s.img.destroy();
s.turret?.destroy();
s.final?.destroy();
this.sprites.delete(id);
}
// -------------------------------------------------------------------------
// Frame
// -------------------------------------------------------------------------
/**
* @param {number} alpha interpolation factor between the last two sim ticks
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/
render(alpha) {
const { state, rules } = this;
this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
const live = new Set();
const gSel = this.gSelection, gBar = this.gBars;
gSel.clear(); gBar.clear();
for (const e of state.entities) {
if (e.dead) continue;
live.add(e.id);
const def = rules.defById[e.defId];
const shown = this.visibleToPlayer(e);
const s = this._ensureSprite(e);
s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown);
if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha;
const y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, alpha);
s.img.setPosition(x, y);
if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row.
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
// Build sites show the wireframe frame. Queued (progress still 0) sits at 50%
// opacity; once work starts the wireframe fades 100%->0% over construction while
// the finished sprite crossfades in underneath, starting at 20% complete.
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
s.img.setAlpha(e.site ? (e.progress <= 0 ? 0.5 : Math.max(0, 1 - e.progress)) : 1);
if (s.final) {
if (e.site) {
s.final.setPosition(x, y);
s.final.setDepth(DEPTHS.actor + (y / state.worldH) * 10 - 0.001);
s.final.setAlpha(Math.max(0, Math.min(1, (e.progress - 0.2) / 0.8)));
} else {
s.final.setAlpha(0);
}
}
// Idle animation for finished resource buildings (energy/mass generators): a slow
// breathing pulse plus a continuous clockwise rotation, so a base full of them reads as
// active rather than static scenery. Each pulse cycle and each quarter-turn re-rolls its
// own random duration, so buildings never fall into visible lockstep with each other.
if (def.isBuilding && def.idleAnimation && !e.site) {
const now = this.scene.time.now;
if (!s.idlePulse || now - s.idlePulse.start >= s.idlePulse.ms) {
s.idlePulse = { start: now, ms: (4 + Math.random() * 4) * 1000 };
}
const pulseT = (now - s.idlePulse.start) / s.idlePulse.ms;
const scale = 0.95 - 0.05 * Math.cos(pulseT * Math.PI * 2);
s.img.setDisplaySize(def.footprint.w * this.ts * scale, def.footprint.h * this.ts * scale);
if (!s.idleRot) s.idleRot = { start: now, ms: 8000 + Math.random() * 4000, turns: 0 };
while (now - s.idleRot.start >= s.idleRot.ms) {
s.idleRot.start += s.idleRot.ms;
s.idleRot.turns++;
s.idleRot.ms = 8000 + Math.random() * 4000;
}
const turnT = (now - s.idleRot.start) / s.idleRot.ms;
s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2));
}
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
}
// Selection ring
if (this.selection.has(e.id)) {
const r = def.isBuilding ? Math.max(def.footprint.w, def.footprint.h) * this.ts * 0.55 : e.radius + 4;
gSel.lineStyle(2, 0x7dff9b, 0.95);
gSel.strokeEllipse(x, y + r * 0.25, r * 2, r * 1.1);
}
// Health / build bars — only when they say something, so we're not stroking 300 of them.
const damaged = e.hp < e.maxHp - 0.5;
if (e.site) {
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff);
} else if (damaged || this.selection.has(e.id) || this.showAllBars) {
const frac = Math.max(0, e.hp / e.maxHp);
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f;
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
}
}
for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id);
this._drawOrderQueues();
this._drawPlacementGhost();
// Actor depth is recomputed every frame from Y, so the container has to be re-sorted
// every frame too — not just when a child is added.
this.worldRoot.sort('depth');
this._layerDirty = false;
// Nanolathe links: builder -> whatever it is working on.
const nanoLinks = [];
for (const e of state.entities) {
if (e.dead || !e.buildTargetId) continue;
if (!this.visibleToPlayer(e)) continue;
const target = state.entities.find((t) => t.id === e.buildTargetId && !t.dead);
if (!target) continue;
nanoLinks.push({
x1: e.x, y1: e.y, x2: target.x, y2: target.y,
color: this.armySheets[e.army].color,
});
}
// Projectiles, interpolated and pre-coloured for the FX layer.
const projectiles = [];
for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon];
const px = p.px + (p.x - p.px) * alpha;
const py = p.py + (p.y - p.py) * alpha;
if (this.fogEnabled && p.army !== this.playerArmy) {
const cell = this.ts * 2;
const gx = Math.floor(px / cell), gy = Math.floor(py / cell);
const army = state.armies[this.playerArmy];
if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue;
if (!army.visible[gy * state.visW + gx]) continue;
}
projectiles.push({
x: px, y: py, vx: p.vx, vy: p.vy,
color: colorInt(w.fx.color), width: w.fx.width ?? 2,
style: w.fx.style, trail: w.fx.trail ? p.trail : null,
});
}
return { nanoLinks, projectiles };
}
/**
* Draw the pending order queue for every selected unit: a waypoint chain from the unit
* through each queued order, coloured by order type.
*
* Queued commands are close to unusable without this — once orders stop replacing each
* other, the player has no way to know what a unit has already been told to do, and a
* queued build is otherwise indistinguishable from one that failed to register.
*/
_drawOrderQueues() {
const g = this.gOrders;
g.clear();
if (!this.selection.size) return;
const qs = this.queueSelection;
let drawn = 0;
for (const e of this.state.entities) {
if (e.dead || e.isBuilding || !this.selection.has(e.id)) continue;
if (!e.orders.length) continue;
// With a big selection every unit has near-identical orders; drawing all of them is
// just cost and clutter.
if (++drawn > MAX_QUEUE_LINES) break;
let px = e.x, py = e.y;
for (let i = 0; i < e.orders.length; i++) {
const o = e.orders[i];
const pt = this._orderPoint(o);
if (!pt) continue;
const col = ORDER_COLORS[o.type] ?? 0x9fe8ff;
const active = i === 0;
g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5);
g.lineBetween(px, py, pt.x, pt.y);
if (o.type === 'build') {
g.lineStyle(2, col, 0.9);
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
} else {
g.fillStyle(col, active ? 0.9 : 0.6);
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
}
// A clicked-and-held queue entry gets a bright ring so its selection is unambiguous
// before the player commits to deleting it.
if (qs && qs.unitId === e.id && qs.index === i) {
g.lineStyle(2.5, 0xffffff, 0.95);
g.strokeCircle(pt.x, pt.y, o.type === 'build' ? 24 : 11);
}
px = pt.x; py = pt.y;
}
}
}
/** World position an order points at, or null if it has no meaningful location. */
_orderPoint(o) {
if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') {
const x = o.sx ?? o.x, y = o.sy ?? o.y;
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
if (o.targetId) {
const t = this.state.entities.find((e) => e.id === o.targetId && !e.dead);
return t ? { x: t.x, y: t.y } : null;
}
return null;
}
/**
* Hit-test a world point against the queue ghosts _drawOrderQueues just drew (same
* selected-unit iteration, same MAX_QUEUE_LINES cap, same per-type marker) so a click only
* ever grabs something the player can actually see. Returns the closest { unitId, index }
* within tolerance, or null.
*/
hitTestQueue(wx, wy) {
let best = null, bestD = Infinity;
let drawn = 0;
for (const e of this.state.entities) {
if (e.dead || e.isBuilding || !this.selection.has(e.id)) continue;
if (!e.orders.length) continue;
if (++drawn > MAX_QUEUE_LINES) break;
for (let i = 0; i < e.orders.length; i++) {
const o = e.orders[i];
const pt = this._orderPoint(o);
if (!pt) continue;
const d = Math.hypot(pt.x - wx, pt.y - wy);
const tol = o.type === 'build' ? 20 : 14;
if (d <= tol && d < bestD) { best = { unitId: e.id, index: i }; bestD = d; }
}
}
return best;
}
_drawPlacementGhost() {
const g = this.gGhost;
g.clear();
const p = this.placement;
if (!p) return;
const ts = this.ts;
const x = p.tx * ts, y = p.ty * ts;
const w = p.def.footprint.w * ts, h = p.def.footprint.h * ts;
g.fillStyle(p.legal ? 0x4affa0 : 0xff5a5a, 0.22);
g.fillRect(x, y, w, h);
g.lineStyle(2, p.legal ? 0x4affa0 : 0xff5a5a, 0.95);
g.strokeRect(x, y, w, h);
// Show the builder's reach so it's obvious why a distant placement won't start.
if (p.builderX != null) {
g.lineStyle(1, 0x8ad4ff, 0.35);
g.strokeCircle(p.builderX, p.builderY, p.buildRange);
}
}
/** Highlight mass spots while a Mass Generator is being placed. */
highlightMassSpots(on) {
if (this.massSpotG) { this.massSpotG.destroy(); this.massSpotG = null; }
if (!on) return;
const { state, rules, ts } = this;
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
this._addWorld(g);
g.lineStyle(2, 0xffd27a, 0.85);
for (let ty = 0; ty < state.h; ty++) {
for (let tx = 0; tx < state.w; tx++) {
const t = rules.terrain[state.terrain[ty * state.w + tx]];
if (!t.massMultiplier) continue;
g.strokeRect(tx * ts + 2, ty * ts + 2, ts - 4, ts - 4);
}
}
this.massSpotG = g;
}
destroy() {
for (const id of [...this.sprites.keys()]) this._releaseSprite(id);
for (const [, chunk] of this.chunks) chunk.rt.destroy();
this.chunks.clear();
this.massSpotG?.destroy();
this.gOrders?.destroy();
this.fogImg?.destroy();
if (this.scene.textures.exists('ta-fog-canvas')) this.scene.textures.remove('ta-fog-canvas');
this._stamp?.destroy();
this.worldRoot.destroy(true);
this.uiRoot.destroy(true);
if (this.uiCam) this.scene.cameras.remove(this.uiCam);
// _setupCameras() bounded, zoomed and re-tinted the scene's MAIN camera for the match —
// that camera is shared with (not owned by) this view and outlives it, so whatever menu
// screen comes next inherits it as-is. Without this, the menu's full-screen rectangles
// (built assuming a camera at scroll (0,0), zoom 1) end up positioned outside whatever
// stale scroll/bounds gameplay left behind — the menu is technically there and clickable,
// just scrolled off into empty space, which reads as a black, unresponsive screen.
const cam = this.scene.cameras.main;
cam.removeBounds?.();
cam.setZoom(1);
cam.setScroll(0, 0);
const bg = this.scene.game?.config?.backgroundColor;
if (bg) cam.setBackgroundColor(bg);
}
}
function drawBar(g, cx, y, w, frac, color) {
const h = 4;
const x = cx - w / 2;
g.fillStyle(0x101418, 0.85);
g.fillRect(x - 1, y - 1, w + 2, h + 2);
g.fillStyle(color, 1);
g.fillRect(x, y, w * Math.max(0, Math.min(1, frac)), h);
}
function lerpAngle(a, b, t) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return a + d * t;
}