diff --git a/data/combat.json b/data/combat.json index 20957c8..8f01e7d 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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", diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index a56e065..67ec829 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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`); diff --git a/js/combat/Combat.js b/js/combat/Combat.js index a6aa053..48f5c98 100644 --- a/js/combat/Combat.js +++ b/js/combat/Combat.js @@ -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) }; +} diff --git a/js/combat/CombatHud.js b/js/combat/CombatHud.js new file mode 100644 index 0000000..f655347 --- /dev/null +++ b/js/combat/CombatHud.js @@ -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 — 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 }; diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index 534aba9..b713393 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -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); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 744ef0a..6273755 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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(); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 14f5274..e8337a8 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/ui/CombatBar.js b/js/ui/CombatBar.js index fa5c0b2..d697768 100644 --- a/js/ui/CombatBar.js +++ b/js/ui/CombatBar.js @@ -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;