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:
parent
ecff203159
commit
0d51184daa
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue