361 lines
15 KiB
JavaScript
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 };
|