// 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; }