// Total Annihilation — screen-space HUD. // // Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera // ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and // calls back into the scene, which is the only thing allowed to issue orders. // // The resource strip is the important piece. A flow economy is unreadable without showing // the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 −24, STALL 74%" // tells them to build another Mass Generator. import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { canAfford, buildOptionsFor } from './TALogic.js'; import { Tooltip } from '../../ui/Tooltip.js'; export const FONT = 'm6x11, "Julius Sans One"'; const PANEL = 0x141a24; const EDGE = 0x39465e; const TEXT = '#e8eef7'; const DIM = '#8fa0b8'; const BAR_H = 62; // top resource strip const BOT_H = 190; // bottom command bar const MINI = 176; // minimap edge length const BTN_W = 88, BTN_H = 74; // build-menu button size const GRID_COLS = 8; // build-menu columns const GRID_PITCH = 96; // column pitch (button + gutter) // The bottom bar has room for exactly ONE row of buttons: a second row runs down into the // hint line at the foot of the panel. GRID_COLS must therefore stay >= the longest `builds` // list in the rules — currently 8, the Constructor and Hover Constructor. Adding a ninth // build option to any unit means widening the grid or moving the hint, not just adding it. // CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and // buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift. const HINT = 'LMB select · drag box · RMB order · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold'; /** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ function shortName(name) { const words = name.split(' '); if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase(); return name.length > 8 ? name.slice(0, 8) : name; } export default class TAHud { constructor(scene, rules, state, view, playerArmy, handlers) { this.scene = scene; this.rules = rules; this.state = state; this.view = view; this.playerArmy = playerArmy; this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump } this.buildButtons = []; this.tooltip = new Tooltip(scene); this.root = scene.add.container(0, 0).setDepth(1000); view.uiRoot.add(this.root); this._buildTopStrip(); this._buildBottomBar(); this._buildMinimap(); this._pulse = 0; } /** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */ get blockRects() { return [ new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H), new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H), ]; } hitsUi(x, y) { return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y)); } // ------------------------------------------------------------------------- // Top strip: MASS and ENERGY as stored/cap plus flow rates // ------------------------------------------------------------------------- _buildTopStrip() { const s = this.scene; const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0); bg.setStrokeStyle(2, EDGE); this.root.add(bg); this.res = {}; const mk = (key, label, x, color) => { const g = s.add.graphics(); const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM }); const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); this.root.add([g, name, amount, flow]); this.res[key] = { g, name, amount, flow, x, color }; }; mk('mass', 'MASS', 28, 0x9fb4cf); mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744); this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', { fontFamily: FONT, fontSize: '26px', color: '#ff6b5a', }).setOrigin(1, 0); this.clock = s.add.text(GAME_WIDTH - 28, 4, '', { fontFamily: FONT, fontSize: '20px', color: DIM, }).setOrigin(1, 0); this.root.add([this.stallText, this.clock]); } _drawResource(key, stored, cap, income, drain, stall) { const r = this.res[key]; const w = 260, h = 12, y = 16; r.g.clear(); r.g.fillStyle(0x0b0e14, 1); r.g.fillRect(r.x + 84, y, w, h); const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0; r.g.fillStyle(r.color, 1); r.g.fillRect(r.x + 84, y, w * frac, h); r.g.lineStyle(1, EDGE, 1); r.g.strokeRect(r.x + 84, y, w, h); r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`); const net = income - drain; r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`); r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0'); // Empty and still draining is the state the player must notice. const critical = stall < 0.98 && stored < cap * 0.02; r.amount.setColor(critical ? '#ff6b5a' : TEXT); } // ------------------------------------------------------------------------- // Bottom bar: selection roster, build/production grid, command buttons // ------------------------------------------------------------------------- _buildBottomBar() { const s = this.scene; const y = GAME_HEIGHT - BOT_H; const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0); bg.setStrokeStyle(2, EDGE); this.root.add(bg); this.selTitle = s.add.text(MINI + 60, y + 12, '', { fontFamily: FONT, fontSize: '26px', color: TEXT, }); this.selDetail = s.add.text(MINI + 60, y + 44, '', { fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3, }); this.queueText = s.add.text(MINI + 60, y + 120, '', { fontFamily: FONT, fontSize: '19px', color: '#9ce6a0', }); this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', { fontFamily: FONT, fontSize: '17px', color: DIM, }).setOrigin(1, 0.5); this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]); this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH; this.gridY = y + 16; } _clearGrid() { for (const b of this.buildButtons) b.destroy(); this.buildButtons.length = 0; } /** * Repaint the build/production grid for the current selection. Buttons carry the def so * the scene can tell "start placing a building" from "enqueue a unit" without re-deriving. */ _drawGrid(options, kind) { this._clearGrid(); const s = this.scene; const army = this.state.armies[this.playerArmy]; options.forEach((def, i) => { const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS); // The container is placed at the button's CENTRE, not its top-left — see below. const x = this.gridX + col * GRID_PITCH + BTN_W / 2; const y = this.gridY + row * 82 + BTN_H / 2; const affordable = canAfford(this.state, this.playerArmy, def); const c = s.add.container(x, y); // A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive() // always builds a hit area CENTRED on the container's position. Children drawn from a // top-left origin therefore sit half a button down-right of their own hitbox. Every // child here is centred on (0,0) so the visual and the hit area coincide. const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1) .setStrokeStyle(2, affordable ? EDGE : 0x2a3244); const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), { fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80', }).setOrigin(0.5, 0); const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, { fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d', }).setOrigin(0.5, 0); c.add([box, label, cost]); c.setSize(BTN_W, BTN_H); c.setInteractive({ useHandCursor: true }); c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1)); c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1)); this.tooltip.attachTo(c, () => this._defTip(def)); c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); if (kind === 'build') this.h.onBuildPick(def); else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1); }); this.root.add(c); this.buildButtons.push(c); }); } _defTip(def) { const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`]; if (def.hp) lines.push(`HP ${def.hp}`); if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); // Which layer a unit lives in decides whether the player's current army can even shoot // it, so it belongs in the tooltip above the individual weapon lines. if (def.isAir) lines.push('AIRCRAFT — only anti-air weapons can hit it'); else if (def.moveClass === 'hover') lines.push('HOVER — crosses water'); for (const w of def.weaponDefs ?? []) { const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only'; lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range / ${dom}${w.manual ? ' (manual)' : ''}`); } if (def.makes) { const m = []; if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`); if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`); if (m.length) lines.push(m.join(' ')); } if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`); if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield'); if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`); return { title: def.name, lines: lines.map((text) => ({ text })) }; } // ------------------------------------------------------------------------- // Minimap — a canvas texture rebaked a few times a second, dots stroked live // ------------------------------------------------------------------------- _buildMinimap() { const s = this.scene; const x = 20, y = GAME_HEIGHT - BOT_H + 8; this.miniX = x; this.miniY = y; const key = `ta-minimap-${s.scene.key}`; if (s.textures.exists(key)) s.textures.remove(key); this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h); this.miniImg = s.add.image(x, y, key).setOrigin(0, 0) .setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true }); this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST); this.miniG = s.add.graphics(); this.root.add([this.miniImg, this.miniG]); const jump = (p) => { const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1); const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1); this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH); }; this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); }); s.input.on('pointerup', () => { this._miniDrag = false; }); s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); }); this._miniBakedAt = -1e9; } _bakeMinimap(timeMs) { if (timeMs - this._miniBakedAt < 250) return; this._miniBakedAt = timeMs; const st = this.state, ctx = this.miniTex.context; const img = ctx.createImageData(st.w, st.h); const pal = this.rules.terrain.map((t) => { const hex = this.view.themePalette?.[t.id] ?? '#404040'; const c = parseInt(String(hex).slice(1), 16); return [(c >> 16) & 255, (c >> 8) & 255, c & 255]; }); const army = st.armies[this.playerArmy]; for (let ty = 0; ty < st.h; ty++) { for (let tx = 0; tx < st.w; tx++) { const i = ty * st.w + tx; const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64]; // The vision grid is half tile resolution, and unexplored must read as black: // a minimap that ignores fog is just a wallhack with extra steps. const v = (ty >> 1) * st.visW + (tx >> 1); const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0; const o = i * 4; img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255; } } ctx.putImageData(img, 0, 0); this.miniTex.refresh(); } _drawMinimapOverlay() { const g = this.miniG, st = this.state; g.clear(); const sx = MINI / st.worldW, sy = MINI / st.worldH; for (const e of st.entities) { if (e.dead) continue; if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; const a = this.rules.armies[e.army]; g.fillStyle(a ? a.colorInt : 0x888888, 1); const r = e.isBuilding ? 2.5 : 1.6; g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2); } const cam = this.scene.cameras.main; g.lineStyle(1.5, 0xffffff, 0.8); g.strokeRect( this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy, cam.worldView.width * sx, cam.worldView.height * sy, ); } // ------------------------------------------------------------------------- // Per-frame update // ------------------------------------------------------------------------- update(timeMs, selection, placementDef) { const st = this.state, army = st.armies[this.playerArmy]; if (!army) return; this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM); this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE); const eff = army.buildEff ?? 1; if (eff < 0.98) { this._pulse = (this._pulse + 0.12) % (Math.PI * 2); this.stallText.setText(`STALL ${Math.round(eff * 100)}%`); this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse))); } else { this.stallText.setText(''); } const t = Math.floor(st.elapsedSec); this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`); this._bakeMinimap(timeMs); this._drawMinimapOverlay(); this._refreshSelection(selection, placementDef); } _refreshSelection(selection, placementDef) { const sel = [...selection]; const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '') + '|' + sel.map((e) => e.queue?.length ?? 0).join(',') // Order-queue length is part of the signature, otherwise queueing a command with CTRL // changes nothing on screen until the selection itself changes. + '|' + sel.map((e) => e.orders?.length ?? 0).join(',') + '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25); if (sig === this._selSig) { this._refreshQueue(sel); return; } this._selSig = sig; if (!sel.length) { this.selTitle.setText(''); this.selDetail.setText(''); this.queueText.setText(''); this.hint.setText(HINT); this._clearGrid(); return; } const counts = new Map(); for (const e of sel) { const def = this.rules.defById[e.defId]; counts.set(def.name, (counts.get(def.name) ?? 0) + 1); } this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' ')); const lead = sel[0]; const def = this.rules.defById[lead.defId]; const lines = []; if (sel.length === 1) { lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`); if (lead.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`); const job = lead.orders?.[0]; if (job?.type === 'repair') lines.push('Repairing'); else if (job?.type === 'build' || job?.type === 'assist') lines.push('Building'); // A builder is the only thing that can repair, so say so where the player is looking. if (!lead.site && (this.rules.defById[lead.defId].builds ?? []).length && !lead.isBuilding) { lines.push('Right-click a damaged ally to repair it'); } if (!lead.site && lead.hasRally) lines.push('Rally point set'); for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); } else { const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0); lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`); const queued = sel.reduce((n, e) => Math.max(n, e.orders?.length ?? 0), 0); if (queued > 1) lines.push(`${queued} orders queued (hold CTRL to add more)`); } this.selDetail.setText(lines.join('\n')); if (placementDef) { this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several ` + '(release CTRL when done), Esc to cancel'); } else { this.hint.setText(HINT); } // Grid shows the builder's structures, or the factory's units — never both. const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build'); else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce'); else this._clearGrid(); } _refreshQueue(sel) { const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length); if (!f) { this.queueText.setText(''); return; } const parts = f.queue.map((q, i) => { const name = this.rules.defById[q.defId].name; const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : ''; return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`; }); this.queueText.setText(`QUEUE ${parts.join(' | ')}`); } toast(msg, color = '#ffd27a') { if (this._toast) this._toast.destroy(); this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, { fontFamily: FONT, fontSize: '28px', color, }).setOrigin(0.5, 0); this.view.uiRoot.add(this._toast); this.scene.tweens.add({ targets: this._toast, alpha: 0, delay: 1400, duration: 600, onComplete: () => { this._toast?.destroy(); this._toast = null; }, }); } destroy() { this._clearGrid(); this.tooltip?.destroy(); this._toast?.destroy(); this.miniG?.destroy(); this.miniImg?.destroy(); this.root.destroy(true); } }