410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
// 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
|
||
|
||
// 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 · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan';
|
||
|
||
/** 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 - 6 * 96;
|
||
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 % 6, row = Math.floor(i / 6);
|
||
// The container is placed at the button's CENTRE, not its top-left — see below.
|
||
const x = this.gridX + col * 96 + 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}`);
|
||
for (const w of def.weaponDefs ?? []) {
|
||
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${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`);
|
||
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);
|
||
}
|
||
}
|