orbit/js/combat/CombatHud.js

361 lines
15 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Fx } from './Fx.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
// Codebase pattern: a local fallback stack passed into fontStack()
// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
import { pickTarget, feedPush, offscreenArrow } from './Combat.js';
const TAU = Math.PI * 2;
/**
* The READABILITY LAYER — the "what do I need to see" side of combat,
* SHARED by both contexts (the range, js/combat/CombatScene.js, and the
* main game via js/scenes/GameScene.js):
*
* - the TARGET/LOCK indicator — rotating amber brackets on the current
* target (the one your guns are on),
* - the THREAT cues — a pulsing hot-red marker above every hostile,
* - the OFF-SCREEN arrows — red edge arrows pointing at hostiles
* outside the camera's view (the main game's scrolling camera; the
* range's arena is one screen, so they rarely fire there),
* - the KILL FEED — the recent kills, top-right, fading,
* - the GAUGE FLASH — a white pulse over the combat bar's shields/hull
* gauge when YOU take that layer's hit (the bar's SHAPE is supplied
* by the context — the main game's js/ui/CombatBar.js, the range's
* — and the flash itself lives HERE, one shared path).
*
* The RULES are pure and tested (js/combat/Combat.js → pickTarget,
* feedPush, offscreenArrow); the numbers are data/combat.json →
* readability.*. This file only draws.
*/
export class CombatHud {
/**
* @param {Phaser.Scene} scene the scene to draw in
* @param {object} [o]
* getEnemies () => Array<object> — the hostiles (anything with
* .x/.y/.dead/.hitRadius — the contexts' EnemyShips)
* getTarget () => object|null — the CURRENT TARGET (the context's
* rule: the range locks the nearest hostile, the main
* game the auto-fire's target — both are pickTarget)
* getCamera () => camera|null — the scrolling camera (main game);
* null in the range (the arena is one fixed screen)
* gaugeRect (kind) => {x,y,w,h}|null — the gauge rect to flash for
* 'shield'/'hull' (the context's own bar; the legacy rect-pulse contract)
* gaugeShape (kind) => {type:'circle',cx,cy,r} | {type:'rect',x,y,w,h}
* | null — the gauge to flash for 'shield'/'hull'. A
* circle blooms as a soft additive dot (the orb's hit
* read); a rect is the white pulse. Takes precedence over
* gaugeRect when present.
* worldToScreen (x, y) => {x, y} — world→viewport (zoom-aware). If
* omitted, falls back to the camera's scroll (zoom 1).
*/
constructor(scene, o = {}) {
this.scene = scene;
this.getEnemies = typeof o.getEnemies === 'function' ? o.getEnemies : () => [];
this.getTarget = typeof o.getTarget === 'function' ? o.getTarget : null;
this.getCamera = typeof o.getCamera === 'function' ? o.getCamera : null;
this.worldToScreen = typeof o.worldToScreen === 'function' ? o.worldToScreen : null;
this.gaugeRect = typeof o.gaugeRect === 'function' ? o.gaugeRect : null;
this.gaugeShape = typeof o.gaugeShape === 'function' ? o.gaugeShape : null;
const r = config.section('combat.readability', {});
this.reticle = r.reticle ?? {};
this.threat = r.threat ?? {};
this.offscreen = r.offscreen ?? {};
this.feedCfg = r.feed ?? {};
this.barFlash = r.barFlash ?? {};
this.feed = []; // [{label, t}] — the pure state (feedPush owns it)
this.feedTexts = []; // live text objects, parallel to this.feed
this.flashes = []; // {rect, t0, life, alpha} — the gauge-flash overlays
this.lastFlash = null; // {kind, at} — the test/CDP readout
this.lastTarget = null; // the last drawn lock target (the CDP readout)
this._now = () =>
scene.time && Number.isFinite(scene.time.now) ? scene.time.now : Date.now();
// World space (follows the ships at any camera scroll):
this._reticleG = this._worldGraphics(11);
this._threatG = this._worldGraphics(11);
// Screen space (pinned to the viewport):
this._arrowG = this._screenGraphics(120);
}
// ------------------------------------------------------------ the draw
/** A world-space graphics object (or null — the stubs without it). */
_worldGraphics(depth) {
if (typeof this.scene.add?.graphics !== 'function') return null;
const g = this.scene.add.graphics().setDepth(depth);
assignWorld(this.scene, g); // world pass (the UI pass must not double-draw)
return g;
}
/** A screen-space graphics object (scrollFactor 0). */
_screenGraphics(depth) {
if (typeof this.scene.add?.graphics !== 'function') return null;
const g = this.scene.add.graphics().setScrollFactor(0).setDepth(depth);
assignUi(this.scene, g); // the UI pass (the world kick must not shake it)
return g;
}
/**
* Step the layer (call once per frame, from the scene's update).
* @param {number} [time] the scene clock (ms) — defaults to scene.time.now
*/
update(time) {
const t = Number.isFinite(time) ? time : this._now();
this._drawReticle(t);
this._drawThreat(t);
this._drawArrows(t);
this._drawFeed(t);
this._stepFlashes(t);
}
/** The lock: rotating amber brackets around the current target. */
_drawReticle(t) {
const g = this._reticleG;
if (!g) return;
const target = this.getTarget ? this.getTarget() : null;
this.lastTarget = target;
if (!target || this.reticle.enabled === false) {
g.clear();
return;
}
const x = target.x;
const y = target.y;
const s = (Number(target.hitRadius) || 20) + (Number(this.reticle.margin) || 16);
const a = ((t * 0.001 * (Number(this.reticle.spin) || 1.6)) % TAU + TAU) % TAU;
const L = s * 0.5;
const color = toColor(this.reticle.color, 0xffd84d);
const cos = Math.cos(a);
const sin = Math.sin(a);
const rot = (px, py) => [x + px * cos - py * sin, y + px * sin + py * cos];
g.clear();
g.lineStyle(3, color, 0.95);
for (const [qx, qy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
const [cx1, cy1] = rot(qx * s, qy * s);
const [ax1, ay1] = rot(qx * (s - L), qy * s);
const [ax2, ay2] = rot(qx * s, qy * (s - L));
g.lineBetween(ax1, ay1, cx1, cy1);
g.lineBetween(cx1, cy1, ax2, ay2);
}
}
/** The threat cues: a pulsing hot-red marker above every hostile. */
_drawThreat(t) {
const g = this._threatG;
if (!g) return;
g.clear();
if (this.threat.enabled === false) return;
const color = toColor(this.threat.color, 0xff2d6f);
const size = Math.max(2, Number(this.threat.size) || 8);
const above = Math.max(0, Number(this.threat.above) || 36);
const pulseHz = Math.max(0.1, Number(this.threat.pulse) || 2.2);
const pulse = 0.55 + 0.45 * Math.sin(t * 0.001 * TAU * pulseHz);
for (const e of this.getEnemies()) {
if (!e || e.dead) continue;
g.fillStyle(color, pulse);
g.fillCircle(e.x, e.y - above, size);
}
}
/** The off-screen arrows: red edge arrows at hostiles outside the view. */
_drawArrows(t) {
const g = this._arrowG;
if (!g) return;
g.clear();
if (this.offscreen.enabled === false) return;
const scale = this.scene.scale ?? {};
const vw = Number(scale.width) || 1280;
const vh = Number(scale.height) || 720;
const pad = Math.max(0, Number(this.offscreen.pad) || 28);
const size = Math.max(4, Number(this.offscreen.size) || 13);
const max = Math.max(1, Number(this.offscreen.max) || 4);
const color = toColor(this.offscreen.color, 0xff2d6f);
const pulse = 0.7 + 0.3 * Math.sin(t * 0.001 * TAU * 2.2);
// world→screen (zoom-aware if the context supplies it, else scroll-1).
const cam = this.getCamera ? this.getCamera() : null;
const toScreen = (x, y) => {
if (this.worldToScreen) return this.worldToScreen(x, y);
const ox = cam ? Number(cam.worldView?.x ?? cam.scrollX ?? 0) : 0;
const oy = cam ? Number(cam.worldView?.y ?? cam.scrollY ?? 0) : 0;
return { x: x - ox, y: y - oy };
};
const cx = vw / 2;
const cy = vh / 2;
// Nearest hostiles first (a clutter cap: the `max` closest arrows win).
const list = this.getEnemies()
.filter((e) => e && !e.dead)
.map((e) => {
const s = toScreen(e.x, e.y);
return { e, s, d: (s.x - cx) ** 2 + (s.y - cy) ** 2 };
})
.sort((p, q) => p.d - q.d)
.slice(0, max);
for (const { s } of list) {
const p = offscreenArrow(vw, vh, s.x, s.y, pad);
if (!p) continue; // on screen — the threat marker covers it
const c = Math.cos(p.angle);
const sn = Math.sin(p.angle);
const px = -sn; // perpendicular
const py = c;
g.fillStyle(color, pulse);
g.fillTriangle(
p.x + c * size * 1.5,
p.y + sn * size * 1.5, // tip (toward the target)
p.x - c * size - px * size,
p.y - sn * size - py * size,
p.x - c * size + px * size,
p.y - sn * size + py * size,
);
}
}
/** The kill feed (top-right, fading; the pure state is Combat.feedPush). */
_drawFeed(t) {
if (this.feedCfg.enabled === false) return;
const max = Math.max(1, Number(this.feedCfg.entries) || 5);
const life = Math.max(200, Number(this.feedCfg.life) || 3000);
// Prune expired entries (and their text objects).
this.feed = this.feed.filter((e) => e && t - e.t < life);
while (this.feed.length > max) this.feed.pop();
// Retire surplus text (the feed shrank).
while (this.feedTexts.length > this.feed.length) {
const o = this.feedTexts.pop();
if (o && typeof o.destroy === 'function') o.destroy();
}
const canText = typeof this.scene.add?.text === 'function';
if (!canText) return; // state still lands (this.feed) — the stubs just don't draw
const scale = this.scene.scale ?? {};
const vw = Number(scale.width) || 1280;
const x = vw - (Number(this.feedCfg.xInset) || 18);
const y0 = Number(this.feedCfg.y0) ?? 64;
const gap = Math.max(10, Number(this.feedCfg.gap) || 19);
const size = Math.max(8, Number(this.feedCfg.size) || 13);
const ink = toCss(this.feedCfg.ink, '#cfe9ff');
for (let i = 0; i < this.feed.length; i++) {
const entry = this.feed[i];
let o = this.feedTexts[i];
if (!o) {
o = this.scene
.add.text(x, y0 + i * gap, '', {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${size}px`,
color: ink,
letterSpacing: 1,
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(120)
.setAlpha(0);
assignUi(this.scene, o); // the UI pass (the world kick must not shake it)
this.feedTexts[i] = o;
}
if (o.text !== entry.label) o.setText(entry.label);
const age = t - entry.t;
// Hold full, then fade out over the last third of the life.
const hold = life * 0.66;
o.setAlpha(age <= hold ? 0.95 : Math.max(0, 0.95 * (1 - (age - hold) / (life - hold))));
}
}
// ------------------------------------------------------------ the flash
/**
* Flash a gauge (the player's hit read on the bar) over the shape the
* context reports for `kind` ('shield'/'hull'). Shape-aware (gaugeShape,
* preferred): a CIRCLE blooms as a soft additive dot (the orb's hit
* read — the dot's soft falloff covers the orb and spills just past
* the rim), a RECT is the classic white pulse. Without a shape, the
* legacy gaugeRect rect is used. The flash is stepped in update() —
* one shared path for both contexts' bars.
*/
flash(kind) {
const t = this._now();
this.lastFlash = { kind, at: t };
const life = Math.max(60, Number(this.barFlash.life) || 340);
const alpha = Math.max(0, Math.min(1, Number(this.barFlash.alpha) ?? 0.85));
const shape = this.gaugeShape ? this.gaugeShape(kind) : null;
let o = null;
if (shape && shape.type === 'circle' && typeof this.scene.add?.image === 'function') {
const key = Fx.ensureDot(this.scene);
o = this.scene.add.image(shape.cx, shape.cy, key);
o.setTint(0xffffff);
o.setBlendMode(Phaser.BlendModes.ADD);
o.setAlpha(0);
// Scale so the dot's 0.26-alpha ring lands on the orb's rim.
o.setScale(Math.max(4, Number(shape.r) || 46) / 17.6);
} else if (typeof this.scene.add?.rectangle === 'function') {
const rect = shape && shape.type === 'rect'
? shape
: this.gaugeRect
? this.gaugeRect(kind)
: null;
if (rect && Number.isFinite(rect.w) && Number.isFinite(rect.h)) {
o = this.scene.add.rectangle(
rect.x + rect.w / 2,
rect.y + rect.h / 2,
rect.w,
rect.h,
0xffffff,
0,
);
}
}
if (!o) return null;
if (typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
if (typeof o.setDepth === 'function') o.setDepth(60);
assignUi(this.scene, o); // the UI pass (the world kick must not shake it)
this.flashes.push({ rect: o, t0: t, life, alpha });
return o;
}
_stepFlashes(t) {
for (let i = this.flashes.length - 1; i >= 0; i--) {
const f = this.flashes[i];
const age = t - f.t0;
if (age >= f.life) {
if (f.rect && typeof f.rect.destroy === 'function') f.rect.destroy();
this.flashes.splice(i, 1);
continue;
}
if (f.rect) f.rect.alpha = f.alpha * (1 - age / f.life);
}
}
// ---------------------------------------------------------------- misc
/** A kill lands in the feed (the pure step is Combat.feedPush). */
kill(label = 'HOSTILE DESTROYED') {
this.feed = feedPush(this.feed, label, this._now(),
Math.max(1, Number(this.feedCfg.entries) || 5),
Math.max(200, Number(this.feedCfg.life) || 3000));
return this.feed;
}
/** Tear down (the scene owns most of it — this is the bookkeeping). */
destroy() {
for (const o of this.feedTexts) if (o && typeof o.destroy === 'function') o.destroy();
for (const f of this.flashes) if (f.rect && typeof f.rect.destroy === 'function') f.rect.destroy();
if (this._reticleG && typeof this._reticleG.destroy === 'function') this._reticleG.destroy();
if (this._threatG && typeof this._threatG.destroy === 'function') this._threatG.destroy();
if (this._arrowG && typeof this._arrowG.destroy === 'function') this._arrowG.destroy();
this.feed = [];
this.feedTexts = [];
this.flashes = [];
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = this.gaugeShape = null;
}
}
/** Re-exported for the contexts — the shared "current target" rule. */
export { pickTarget };