Readability layer: lock reticle, threat cues, off-screen arrows, kill feed, bar gauge flash

A shared readability layer (js/combat/CombatHud.js) for both combat
contexts — the training range and the main game's ?combat:

- Target/lock indicator: rotating brackets on the current target (the
  auto-fire target — one rule, one target, js/combat/Combat.js#pickTarget).
- Threat cues: pulsing markers above each live hostile.
- Off-screen arrows: edge arrows toward hostiles outside the camera
  (zoom-aware via a context-supplied worldToScreen).
- Kill feed: newest-first fading entries, top-right.
- Gauge flash: the combat bar's shields/hull gauge flashes on the hit
  it took (CombatBar.gaugeRect feeds the shared CombatHud.flash).

Pure rules stay in Combat.js (pickTarget, feedPush, offscreenArrow),
covered by dev/combat.test.mjs. The main game re-anchors the pack
around the ship once its start position settles (CombatSystem
.resyncAroundShip + a short settle window in GameScene), so the demo
pack sits in auto-fire range instead of kilometers off.

All combat rules tests pass (0 failures); CDP-verified in both
/combat and index.html?combat.
This commit is contained in:
Brian Fertig 2026-09-16 02:02:55 -06:00
parent ecff203159
commit 0d51184daa
8 changed files with 690 additions and 17 deletions

View File

@ -99,6 +99,15 @@
"respawnDelay": 1.6
},
"readability": {
"_comment": "The READABILITY LAYER (js/combat/CombatHud.js) — the 'what do I need to see' side of combat, shared by both contexts. reticle = the target/LOCK indicator: rotating corner brackets on the current target (the one your guns are on — the range's nearest hostile, the main game's auto-fire target; the shared rule is Combat.js → pickTarget). margin = px outside the target's hitRadius, spin = rad/s bracket rotation, color = the lock's amber. threat = the hostile cue: a small pulsing marker above EVERY enemy (size px, pulse 1/s, above = px over the ship's center, color = hot red). offscreen = the edge ARROWS for hostiles outside the camera's view (main game — the range's arena is one screen, so they rarely fire there): pad = px inside the screen edge, size = arrow px, max = the most arrows drawn (nearest hostiles win), color. feed = the KILL FEED, top-right (entries = max lines, life ms per entry, gap px between lines, size px font, xInset = px in from the screen's RIGHT edge, y0 = px DOWN from the top (the first line), ink + hit colors). barFlash = the combat bar's GAUGE FLASH when YOU take a hit: life ms, alpha = the white overlay's start (the shield gauge flashes on shield hits, the hull gauge on hull hits — both when the shields pop through).",
"reticle": { "enabled": true, "margin": 16, "spin": 1.6, "color": "#ffd84d" },
"threat": { "enabled": true, "size": 8, "pulse": 2.2, "above": 36, "color": "#ff2d6f" },
"offscreen": { "enabled": true, "pad": 28, "size": 13, "max": 4, "color": "#ff2d6f" },
"feed": { "enabled": true, "entries": 5, "life": 3000, "gap": 19, "size": 13, "xInset": 18, "y0": 64, "ink": "#cfe9ff", "kill": "#ffd84d" },
"barFlash": { "life": 340, "alpha": 0.85 }
},
"hud": {
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line at the bottom.",
"title": "COMBAT // SANDBOX",

View File

@ -94,6 +94,49 @@ function makeImg(x = 0, y = 0, key = '__dot') {
return o;
}
// The readability layer's seams (js/combat/CombatHud.js) — just enough
// of the display list for it to run headless and be asserted against.
function makeGraphics() {
return {
visible: true,
clear() { return this; },
lineStyle() { return this; },
fillStyle() { return this; },
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.destroyed = true; },
};
}
function makeTextObj() {
return {
text: '', alpha: 0, visible: true,
setText(t) { this.text = String(t); return this; },
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) {
return {
x, y, width: w, height: h, alpha, visible: true,
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
@ -101,6 +144,9 @@ function makeScene({ withSheet = true } = {}) {
add: {
existing: (o) => o,
image: (x, y, key) => makeImg(x, y, key),
graphics: () => makeGraphics(),
text: (x, y, s) => makeTextObj(),
rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
@ -120,12 +166,13 @@ for (const f of fs.readdirSync(dataDir)) {
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { applyDamage, headingOf, orbitStep, handleFeel } = await import(
const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, offscreenArrow } = await import(
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href);
const {
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.js')).href);
@ -375,6 +422,28 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
combat.update(target.respawnAt + 10, 16);
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
// CURRENT TARGET (the readability layer's lock): the auto-fire target —
// the nearest live hostile WITHIN auto-fire range (the shared rule).
check('system: currentTarget = the nearest live hostile in range',
combat.currentTarget() === combat.nearestEnemy(ship.x, ship.y) && combat.currentTarget() !== null);
combat.autoFire.range = 5;
check('system: currentTarget honors the auto-fire range (none in 5px)', combat.currentTarget() === null);
combat.autoFire.range = 800;
// RE-ANCHOR (the main game): the ship's start position settles AFTER
// create (the tether barrier) — the pack gets pulled around it.
ship.x = 1200; ship.y = 300;
const moved = combat.resyncAroundShip();
let cx = 0, cy = 0, n = 0;
for (const e of combat.enemies) { if (!e.dead) { cx += e.x; cy += e.y; n++; } }
cx /= n; cy /= n;
check('system: resync pulls the pack around the settled ship',
moved === true && Math.abs(cx - 1200) < 1e-6 && Math.abs(cy - 300) < 1e-6);
check('system: resync is a no-op once anchored', combat.resyncAroundShip() === false);
check('system: resync with no ship is a no-op', (combat.ship = null, combat.resyncAroundShip()) === false);
combat.ship = ship;
ship.x = 0; ship.y = 0;
// RAM: the ship inside the enemy's combined radius → both take damage.
const ram = combat.enemies[0];
ship.x = 0; ship.y = 0;
@ -489,6 +558,81 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
}
// ------------------------------------------------------- readability layer
{
// pickTarget — the shared "current target" rule (the range's lock and
// the main game's auto-fire both run it).
const A = { x: 10, y: 0, dead: false, hitRadius: 20 };
const B = { x: 3, y: 0, dead: true, hitRadius: 20 }; // dead — never qualifies
const C = { x: 100, y: 0, dead: false, hitRadius: 20 };
check('read: pickTarget = the nearest LIVE hostile', pickTarget([A, B, C], 0, 0) === A);
check('read: pickTarget skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A);
check('read: pickTarget honors the range (and nulls outside it)',
pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null);
check('read: pickTarget empty / all-dead → null',
pickTarget([], 0, 0) === null && pickTarget([B], 0, 0) === null);
check('read: pickTarget tolerates missing entries', pickTarget([null, undefined, A], 0, 0) === A);
// feedPush — the kill feed's pure state step.
const f1 = feedPush([], 'KILL A', 1000, 5, 3000);
const f2 = feedPush(f1, 'KILL B', 2000, 5, 3000);
check('read: feed lands the newest first (and keeps the rest)',
f2.length === 2 && f2[0].label === 'KILL B' && f2[1].label === 'KILL A');
check('read: feed caps at the entry count',
feedPush(feedPush(feedPush([], 'a', 0, 2, 3000), 'b', 1, 2, 3000), 'c', 2, 2, 3000).length === 2);
const f3 = feedPush(feedPush([], 'KILL OLD', 0, 5, 3000), 'KILL NEW', 4000, 5, 3000);
check('read: feed drops the expired (life 3000ms)', f3.length === 1 && f3[0].label === 'KILL NEW');
// offscreenArrow — the edge-arrow geometry (pure).
const VW = 1280, VH = 720, PAD = 28;
check('read: on-screen target → no arrow', offscreenArrow(VW, VH, 640, 360, PAD) === null);
const right = offscreenArrow(VW, VH, 2000, 360, PAD);
check('read: off-screen right → arrow on the right edge, aimed +x',
right && Math.abs(right.x - (VW - PAD)) < 1e-9 && Math.abs(right.angle) < 1e-9);
const up = offscreenArrow(VW, VH, 640, -500, PAD);
check('read: off-screen up → arrow on the top edge, aimed -y',
up && Math.abs(up.y - PAD) < 1e-9 && approx(up.angle, -Math.PI / 2, 1e-9));
const diag = offscreenArrow(VW, VH, 2000, -500, PAD);
check('read: diagonal → the point sits on the inset edge, aimed NE',
diag &&
(Math.abs(diag.x - (VW - PAD)) < 1e-9 || Math.abs(diag.y - PAD) < 1e-9) &&
diag.angle < 0 && diag.angle > -Math.PI / 2);
// CombatHud — the shared layer running headless on the stub scene.
const scene = makeScene({ withSheet: true });
const enemies = [
{ x: 300, y: 300, dead: false, hitRadius: 20 },
{ x: 900, y: 400, dead: true, hitRadius: 20 }, // the threat cue skips it
];
const rects = { shield: { x: 44, y: 75, w: 210, h: 9 }, hull: { x: 44, y: 35, w: 210, h: 9 } };
const hud = new CombatHud(scene, {
getEnemies: () => enemies,
getTarget: () => enemies[0],
gaugeRect: (k) => rects[k] ?? null,
});
check('read: hud.kill lands the feed entry (the pure step)',
hud.kill('HOSTILE DESTROYED')[0].label === 'HOSTILE DESTROYED');
const overlay = hud.flash('hull');
check('read: hud.flash records the layer + the gauge rect',
overlay && overlay.width === 210 && overlay.height === 9 && hud.lastFlash.kind === 'hull');
hud.update(200); // the draw pass (reticle/threat/arrows/feed) + flash step
check('read: the draw pass runs; the flash is fading',
overlay.alpha > 0 && overlay.alpha < 0.85);
hud.update(400); // past the 340ms life → the overlay retires
check('read: the flash retires after its life',
hud.flashes.length === 0 && overlay.destroyed === true);
hud.destroy();
check('read: hud.destroy clears the bookkeeping',
hud.feed.length === 0 && hud.feedTexts.length === 0 && hud.flashes.length === 0);
// No gauge rects (a context without a bar) → the flash is a safe no-op
// that still records the layer (the dev-log contract).
const hud2 = new CombatHud(makeScene({ withSheet: true }), { getEnemies: () => [] });
check('read: flash without a gauge is a recorded no-op',
hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield');
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

View File

@ -119,3 +119,86 @@ export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRad
y: py + Math.sin(angle) * orbitRadius,
};
}
// ---------------------------------------------------- the readability layer
//
// The READ rules behind the readability layer (js/combat/CombatHud.js) —
// pure, so both contexts (the range AND the main game) share the same
// "what counts as the current target / where does the off-screen arrow
// land / what the feed shows" logic. data/combat.json → readability.*
// carries the numbers; CombatHud does the drawing.
/**
* The CURRENT TARGET (the one your guns are on): the nearest LIVE hostile
* to (x, y) within `range` px, or null. The range's lock indicator and the
* main game's auto-fire both run this rule, so "what's locked" never
* drifts between the two. Dead hostiles never qualify.
*
* @returns {object|null} the target (anything with .x/.y/.dead)
*/
export function pickTarget(enemies, x, y, range = Infinity) {
let best = null;
let bestD = Infinity;
const r2 = range * range;
for (const e of enemies || []) {
if (!e || e.dead) continue;
const dx = e.x - x;
const dy = e.y - y;
const d2 = dx * dx + dy * dy;
if (d2 <= r2 && d2 < bestD) {
bestD = d2;
best = e;
}
}
return best;
}
/**
* One kill-feed entry lands (pure state step): unshift {label, t: now},
* drop anything older than `life` ms, cap at `max` lines. Returns a NEW
* array (the caller's stays put — the same rule the range and the main
* game's feed both run).
*
* @returns {Array<{label: string, t: number}>} the next feed state
*/
export function feedPush(feed, label, now, max = 5, life = 3000) {
const kept = (feed || []).filter((e) => e && now - e.t < life);
return [{ label, t: now }, ...kept].slice(0, Math.max(1, max));
}
/**
* Where the OFF-SCREEN arrow for a hostile sits: the point where the ray
* from the viewport's center to the target's SCREEN position (sx, sy)
* meets the viewport's edge, inset by `pad` px — plus the angle to aim
* the arrow at the target. Returns null while the target is on screen
* (nothing to point at). Pure geometry — no scene, no Phaser.
*
* @param {number} vw the viewport's width (px)
* @param {number} vh the viewport's height (px)
* @param {number} sx the target's x in SCREEN space
* @param {number} sy the target's y in SCREEN space
* @param {number} [pad] the inset from the screen edge (px)
* @returns {{x: number, y: number, angle: number}|null}
*/
export function offscreenArrow(vw, vh, sx, sy, pad = 28) {
const px = Math.max(0, pad);
const ix = px;
const iy = px;
const iw = Math.max(1, vw - 2 * px);
const ih = Math.max(1, vh - 2 * px);
// On screen (inside the inset rect)? Nothing to point at.
if (sx >= ix && sx <= ix + iw && sy >= iy && sy <= iy + ih) return null;
const cx = ix + iw / 2;
const cy = iy + ih / 2;
const dx = sx - cx;
const dy = sy - cy;
if (dx === 0 && dy === 0) return null; // exactly on center — degenerate
// Ray from the center: smallest t that puts us on the inset rect's edge.
let t = Infinity;
if (dx !== 0) t = Math.min(t, (dx > 0 ? iw / 2 / dx : -iw / 2 / dx));
if (dy !== 0) t = Math.min(t, (dy > 0 ? ih / 2 / dy : -ih / 2 / dy));
if (!Number.isFinite(t) || t <= 0) return null;
const x = cx + t * dx;
const y = cy + t * dy;
return { x, y, angle: Math.atan2(dy, dx) };
}

325
js/combat/CombatHud.js Normal file
View File

@ -0,0 +1,325 @@
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';
// 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 rect is supplied
* by the context — the main game's js/ui/CombatBar.js, the range's
* HUD — 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)
* 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;
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;
return this.scene.add.graphics().setDepth(depth);
}
/** A screen-space graphics object (scrollFactor 0). */
_screenGraphics(depth) {
if (typeof this.scene.add?.graphics !== 'function') return null;
return this.scene.add.graphics().setScrollFactor(0).setDepth(depth);
}
/**
* 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);
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): a white pulse over
* the rect the context reports for `kind` ('shield'/'hull'). The flash
* is stepped in update() — one shared path for the main bar and the
* range's HUD gauges.
*/
flash(kind) {
const t = this._now();
this.lastFlash = { kind, at: t };
const rect = this.gaugeRect ? this.gaugeRect(kind) : null;
if (!rect || typeof this.scene.add?.rectangle !== 'function') return null;
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 o = this.scene.add.rectangle(
rect.x + rect.w / 2,
rect.y + rect.h / 2,
rect.w,
rect.h,
0xffffff,
0,
);
if (typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
if (typeof o.setDepth === 'function') o.setDepth(60);
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 = null;
}
}
/** Re-exported for the contexts — the shared "current target" rule. */
export { pickTarget };

View File

@ -11,8 +11,9 @@ import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js';
import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel } from './Combat.js';
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
import { CombatHud } from './CombatHud.js';
/**
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
@ -137,6 +138,19 @@ export class CombatScene extends Phaser.Scene {
// The range console (HUD).
this.createHud();
// The READABILITY LAYER (js/combat/CombatHud.js — shared with the
// main game): the lock on the current target (the nearest hostile —
// the same pickTarget rule the main game's auto-fire runs), the
// threat markers, the off-screen arrows (rare — the arena is one
// screen), the kill feed, and the HUD gauge flash on player hits.
this.hud = new CombatHud(this, {
getEnemies: () => this.enemies,
getTarget: () =>
this.playerDead || !this.ship ? null : pickTarget(this.enemies, this.ship.x, this.ship.y),
// No scrolling camera — the arena is a fixed full screen.
gaugeRect: (kind) => this.gaugeRects?.[kind] ?? null,
});
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause.
//
// NOTE — this build (Phaser 4.2.1 Giedi): `addKeys({up:'W,UP',…})`
@ -221,6 +235,7 @@ export class CombatScene extends Phaser.Scene {
this.fx.update(delta);
this.starfield.update(time, delta);
this.hud.update(time); // the readability layer (lock/threat/feed/flash)
this.updateHud(time);
}
@ -385,6 +400,7 @@ export class CombatScene extends Phaser.Scene {
this.kills += 1;
this.fx.explode(e.x, e.y, 0xff5d6f);
combatSfx(this, 'kill');
if (this.hud) this.hud.kill('HOSTILE DESTROYED'); // the kill feed
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
@ -604,6 +620,11 @@ export class CombatScene extends Phaser.Scene {
combatSfx(this, 'impact');
if (shieldHit > 0) combatSfx(this, 'shield');
if (hullHit > 0) combatSfx(this, 'hull');
// The player-hit read on the gauges (the layer(s) that took it).
if (this.hud) {
if (shieldHit > 0) this.hud.flash('shield');
if (hullHit > 0) this.hud.flash('hull');
}
if (dead) this.killPlayer();
}
@ -729,6 +750,12 @@ export class CombatScene extends Phaser.Scene {
}).setOrigin(0, 0.5).setDepth(D);
this.shdBarBg = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, 0x101a2c).setDepth(D);
this.shdBar = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, neon).setDepth(D);
// The gauge rects (for the readability layer's hit-flash — the same
// shared flash the main game's combat bar gets).
this.gaugeRects = {
hull: { x: bx, y: py + 35.5, w: bw, h: 9 },
shield: { x: bx, y: py + 75.5, w: bw, h: 9 },
};
this.shdText = this.add.text(numX, py + 80, '40', {
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
}).setOrigin(0, 0.5).setDepth(D);

View File

@ -2,7 +2,7 @@ import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js';
import { applyDamage } from './Combat.js';
import { applyDamage, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
const TAU = Math.PI * 2;
@ -215,19 +215,51 @@ export class CombatSystem {
this.fx.update(delta);
}
/** The nearest live hostile to (x, y), or null. */
/** The nearest live hostile to (x, y), or null (the shared pickTarget rule). */
nearestEnemy(x, y) {
let best = null;
let bestD = Infinity;
return pickTarget(this.enemies, x, y);
}
/**
* The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js):
* the hostile the auto-fire is on — the nearest one WITHIN auto-fire
* range (the same pickTarget rule, ranged by game.autoFire.range — so
* the brackets sit on exactly the ship the cannon is tracking).
*/
currentTarget() {
if (!this.ship) return null;
return pickTarget(this.enemies, this.ship.x, this.ship.y, this.autoFire.range);
}
/**
* RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's
* start position settles AFTER create() (the tether barrier / a save
* restore places it at the starting system's rim; the fallback spawn
* is the origin), so a pack spawned around the create-time position
* would sit kilometers off. Shifts every live enemy so the pack's
* centroid lands on the ship (each enemy's ring angle is preserved).
* A no-op once anchored (within 64px), with no ship, or with no live
* enemies. The range doesn't need it (its ship never settles away)
* — but the shared method is where the rule lives.
* @returns {boolean} true if the pack moved
*/
resyncAroundShip() {
if (!this.ship) return false;
let cx = 0, cy = 0, n = 0;
for (const e of this.enemies) {
if (!e.dead) { cx += e.x; cy += e.y; n++; }
}
if (n === 0) return false;
cx /= n; cy /= n;
const dx = this.ship.x - cx;
const dy = this.ship.y - cy;
if (Math.hypot(dx, dy) < 64) return false; // already anchored
for (const e of this.enemies) {
if (e.dead) continue;
const d = Math.hypot(e.x - x, e.y - y);
if (d < bestD) {
bestD = d;
best = e;
}
e.x += dx;
e.y += dy;
}
return best;
return true;
}
// --------------------------------------------------------------- firing
@ -425,7 +457,8 @@ export class CombatSystem {
combatSfx(this.scene, 'impact');
if (shieldHit > 0) combatSfx(this.scene, 'shield');
if (hullHit > 0) combatSfx(this.scene, 'hull');
if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields });
if (this.onPlayerDamage)
this.onPlayerDamage(n, { hull: this.hull, shields: this.shields, shieldHit, hullHit });
if (dead) this.killPlayer();
}

View File

@ -35,6 +35,7 @@ import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { CombatBar } from '../ui/CombatBar.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { CombatHud } from '../combat/CombatHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
@ -930,8 +931,40 @@ export class GameScene extends Phaser.Scene {
this.combat = new CombatSystem(this, {
ship: this.ship,
onActiveChange: (active) => this._setCombatBar(active),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the
// feed, and a hit flashes the bar's gauge that took it.
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
onPlayerDamage: (n, a) => {
if (!this.combatHud || !a) return;
if (a.shieldHit > 0) this.combatHud.flash('shield');
if (a.hullHit > 0) this.combatHud.flash('hull');
},
});
this.combat.spawnPack(this.ship.x, this.ship.y);
// SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier;
// a load's from the save), so the pack — spawned around the
// create-time position — is re-anchored around the ship for the
// first few frames (update() → CombatSystem.resyncAroundShip).
this._combatSettleFrames = 30;
// The READABILITY LAYER (shared with the range): the lock on the
// auto-fire target, the threat markers, the off-screen arrows
// (the camera follows the ship — hostiles can sit outside it),
// the kill feed, and the combat bar's gauge flash on player hits.
this.combatHud = new CombatHud(this, {
getEnemies: () => (this.combat ? this.combat.enemies : []),
getTarget: () => this.combat?.currentTarget() ?? null,
getCamera: () => this.cameras?.main ?? null,
worldToScreen: (x, y) => {
const c = this.cameras?.main;
const ox = c?.worldView?.x ?? c?.x ?? 0;
const oy = c?.worldView?.y ?? c?.y ?? 0;
const z = c?.zoom ?? 1;
return { x: (x - ox) * z, y: (y - oy) * z };
},
gaugeRect: (kind) => this.combatBar?.gaugeRect(kind) ?? null,
});
if (typeof window !== 'undefined') {
// Tuning handle: poke the pack / the armor / the bar from the console.
window.__ORBIT_COMBAT__ = {
@ -2185,9 +2218,18 @@ export class GameScene extends Phaser.Scene {
// COMBAT (the ?combat demo): the pack's AI + lances, the ship's
// auto-fire, the tracers, the rams, the player's armor — frozen by an
// ACK comm's pause, like the ship.
if (this.combat && !_commsPaused) {
this.combat.update(_time, delta);
this.combatBar?.setStats(this.combat);
if (this.combat) {
// The settle window (above): one-shot re-anchor of the pack while
// the ship's start position settles; a no-op once anchored.
if (this._combatSettleFrames > 0) {
this._combatSettleFrames--;
if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0;
}
if (!_commsPaused) {
this.combat.update(_time, delta);
this.combatBar?.setStats(this.combat);
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
}
}
this.engineFx?.update(_time, delta); // burn fades even through a pause
// Session time (saved with the game) — capped so a backgrounded tab

View File

@ -91,7 +91,7 @@ export class CombatBar extends Phaser.GameObjects.Container {
.setOrigin(0.5, 0.5)
.setScrollFactor(0);
this.add(labelT);
return { fill, frac: 1 };
return { fill, frac: 1, track: { x: cx - gaugeW / 2, y: gaugeTop, w: gaugeW, h: gaugeH } };
};
const leftX = x0 + pad + gaugeW / 2;
@ -173,6 +173,16 @@ export class CombatBar extends Phaser.GameObjects.Container {
);
}
/**
* The gauge's TRACK rect (for the readability layer's hit-flash —
* js/combat/CombatHud.js flashes this rect when the player takes that
* layer's hit): 'shield' or 'hull'.
*/
gaugeRect(kind) {
const g = kind === 'hull' ? this.gHull : this.gShield;
return g && g.track ? g.track : null;
}
/** Tear down (scene destroy) — the children go with the scene anyway. */
destroy() {
this.onCast = null;