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

428 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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