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 — 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 };