681 lines
26 KiB
JavaScript
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;
|
|
}
|