Rework combat bar into bottom-centered orb strip

- Replace vertical gauges with draining hull/shield/energy orbs
  (hull with overlapping shield on the left, energy on the right).
- Share the main game's CombatBar in the combat range and add
  range energy tracking with optional regen.
- Make gauge hit flashes shape-aware: circle orbs get a soft bloom,
  rects keep the legacy white pulse.
- Update combat data and assets for the new orb-strip storyboard.
This commit is contained in:
Brian Fertig 2026-09-16 16:14:35 -06:00
parent 11f315dbf0
commit c352e089e8
8 changed files with 362 additions and 201 deletions

BIN
assets/bar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -14,7 +14,7 @@
},
"energy": {
"_comment": "The player's ENERGY — the skill resource (the combat bar's right gauge). The eight skills (keys 1-8, the bar's middle seats) will spend it; for now it just sits full. regen = energy/s recovered (0 = static).",
"_comment": "The player's ENERGY — the skill resource (the combat bar's right ORB — the main game's and the range's). The eight skills (keys 1-8, the bar's middle seats) will spend it; for now it just sits full. regen = energy/s recovered (0 = static).",
"max": 100,
"regen": 0
},
@ -153,27 +153,32 @@
},
"hud": {
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line at the bottom.",
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line (top-left — the bottom-center is the combat bar's now).",
"title": "COMBAT // SANDBOX",
"hints": "WASD / arrows — fly · space — fire · R — reset range · P — pause"
},
"bar": {
"_comment": "The COMBAT BAR (js/ui/CombatBar.js) — shown in place of the action deck while combat is live (the deck hides, the bar takes its place; when the pack is wiped it's the other way around). geometry = the bar's screen rect (margin/height mirror actionbar's, so it sits in the deck's place). Left = the vertical SHIELDS + HULL gauges; right = the vertical ENERGY gauge; middle = slot.count skill seats wearing keys 1-8 (the skills land later — for now they are empty seats). Colors fall back to data/theme.json where sensible.",
"height": 92,
"margin": { "left": 20, "right": 20, "bottom": 12 },
"_comment": "The COMBAT BAR (js/ui/CombatBar.js) — shown in place of the action deck while combat is live (the deck hides, the bar takes its place; when the pack is wiped it's the other way around). geometry = a BOTTOM-ALIGNED, CENTERED strip sized to its contents (assets/bar.png is the storyboard — not a color ref). Left end = the hull ORB (red) with the SHIELD orb (cyan, semi-transparent) overlapping it — shields in front of hull; middle = slot.count skill seats wearing keys 1-8 (the skills land later — for now they are empty seats); right end = the ENERGY orb (yellow, the skills' resource). The orbs fill like a Diablo orb: the liquid drains as the value drops. circle.radius = the hull/energy orb radius; circle.shield.scale = the shield orb's radius as a fraction of the hull radius — the shield orb shares the hull's CENTER (offsetX/offsetY default 0) and its liquid is semi-transparent over the hull's; orbGap = orb-to-slot spacing; margin.edge = the strip's overhang past the orbs. Colors fall back to data/theme.json where sensible.",
"strip": { "height": 64 },
"margin": { "bottom": 26, "edge": 10 },
"orbGap": 24,
"circle": {
"radius": 46,
"hull": { "fillAlpha": 0.95 },
"shield": { "scale": 1.12, "offsetX": 0, "offsetY": 0, "fillAlpha": 0.55 }
},
"colors": {
"bg": "#070d18",
"bgAlpha": 0.92,
"edge": "#22405f",
"shield": "#00e5ff",
"hull": "#7dff8a",
"hull": "#ff2d6f",
"energy": "#ffd84d",
"slotBg": "#0b1322",
"slotBorder": "#22405f",
"key": "#5c7196"
},
"gauge": { "width": 14, "gap": 8, "pad": 12, "labelSize": 11 },
"slot": { "count": 8, "size": 64, "gap": 10, "keySize": 12 }
"slot": { "count": 8, "size": 44, "gap": 10, "keySize": 12 }
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -2,6 +2,7 @@ import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Fx } from './Fx.js';
// Codebase pattern: a local fallback stack passed into fontStack()
// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
@ -23,9 +24,9 @@ const TAU = Math.PI * 2;
* 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
* gauge when YOU take that layer's hit (the bar's SHAPE is supplied
* by the context — the main game's js/ui/CombatBar.js, the range's
* HUD — and the flash itself lives HERE, one shared path).
* — 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 →
@ -43,7 +44,12 @@ export class CombatHud {
* 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)
* 'shield'/'hull' (the context's own bar; the legacy rect-pulse contract)
* gaugeShape (kind) => {type:'circle',cx,cy,r} | {type:'rect',x,y,w,h}
* | null — the gauge to flash for 'shield'/'hull'. A
* circle blooms as a soft additive dot (the orb's hit
* read); a rect is the white pulse. Takes precedence over
* gaugeRect when present.
* worldToScreen (x, y) => {x, y} — world→viewport (zoom-aware). If
* omitted, falls back to the camera's scroll (zoom 1).
*/
@ -54,6 +60,7 @@ export class CombatHud {
this.getCamera = typeof o.getCamera === 'function' ? o.getCamera : null;
this.worldToScreen = typeof o.worldToScreen === 'function' ? o.worldToScreen : null;
this.gaugeRect = typeof o.gaugeRect === 'function' ? o.gaugeRect : null;
this.gaugeShape = typeof o.gaugeShape === 'function' ? o.gaugeShape : null;
const r = config.section('combat.readability', {});
this.reticle = r.reticle ?? {};
@ -258,26 +265,47 @@ export class CombatHud {
// ------------------------------------------------------------ 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 a gauge (the player's hit read on the bar) over the shape the
* context reports for `kind` ('shield'/'hull'). Shape-aware (gaugeShape,
* preferred): a CIRCLE blooms as a soft additive dot (the orb's hit
* read — the dot's soft falloff covers the orb and spills just past
* the rim), a RECT is the classic white pulse. Without a shape, the
* legacy gaugeRect rect is used. The flash is stepped in update() —
* one shared path for both contexts' bars.
*/
flash(kind) {
const t = this._now();
this.lastFlash = { kind, at: t };
const 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,
);
const shape = this.gaugeShape ? this.gaugeShape(kind) : null;
let o = null;
if (shape && shape.type === 'circle' && typeof this.scene.add?.image === 'function') {
const key = Fx.ensureDot(this.scene);
o = this.scene.add.image(shape.cx, shape.cy, key);
o.setTint(0xffffff);
o.setBlendMode(Phaser.BlendModes.ADD);
o.setAlpha(0);
// Scale so the dot's 0.26-alpha ring lands on the orb's rim.
o.setScale(Math.max(4, Number(shape.r) || 46) / 17.6);
} else if (typeof this.scene.add?.rectangle === 'function') {
const rect = shape && shape.type === 'rect'
? shape
: this.gaugeRect
? this.gaugeRect(kind)
: null;
if (rect && Number.isFinite(rect.w) && Number.isFinite(rect.h)) {
o = this.scene.add.rectangle(
rect.x + rect.w / 2,
rect.y + rect.h / 2,
rect.w,
rect.h,
0xffffff,
0,
);
}
}
if (!o) return null;
if (typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
if (typeof o.setDepth === 'function') o.setDepth(60);
this.flashes.push({ rect: o, t0: t, life, alpha });
@ -317,7 +345,7 @@ export class CombatHud {
this.feed = [];
this.feedTexts = [];
this.flashes = [];
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = null;
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = this.gaugeShape = null;
}
}

View File

@ -15,6 +15,7 @@ import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
import { CombatHud } from './CombatHud.js';
import { CombatBar } from '../ui/CombatBar.js';
/**
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
@ -106,6 +107,13 @@ export class CombatScene extends Phaser.Scene {
this.maxShields = Math.max(0, config.get('combat.playerShip.shields', 40));
this.hull = this.maxHull;
this.shields = this.maxShields;
// ENERGY — the skill resource (→ energy; the bar's right orb). The
// range's skills land with the real combat port — for now the orb
// just sits full (regen = energy/s, 0 = static).
const en = config.section('combat.energy', {});
this.maxEnergy = Math.max(1, Number(en.max) || 100);
this.energy = this.maxEnergy;
this.energyRegen = Math.max(0, Number(en.regen) || 0);
this.lastHitAt = -1e9;
this.ship = new Ship(this, w * 0.24, h / 2);
this.ship.setDepth(10);
@ -148,7 +156,7 @@ export class CombatScene extends Phaser.Scene {
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,
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
});
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause.
@ -314,6 +322,10 @@ export class CombatScene extends Phaser.Scene {
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
}
}
// Energy regen (energy.* — the bar's right orb; static when regen 0).
if (this.energy < this.maxEnergy && this.energyRegen > 0) {
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
}
}
firePlayer(heading, wpn) {
@ -645,6 +657,7 @@ export class CombatScene extends Phaser.Scene {
if (!config.get('combat.sandbox.respawn', true) || time < this.respawnAt) return;
this.hull = this.maxHull;
this.shields = this.maxShields;
this.energy = this.maxEnergy;
this.ship.x = this.w * 0.24;
this.ship.y = this.h / 2;
this.ship.rotation = 0;
@ -697,7 +710,6 @@ export class CombatScene extends Phaser.Scene {
const hud = config.section('combat.hud', {});
const headerFont = fontStack('header', FONT_FALLBACK);
const bodyFont = fontStack('body', FONT_FALLBACK);
const ink = themeColor('ink', 0xeaf6ff);
const dim = themeColor('dim', 0x8aa0c0);
const faint = themeColor('faint', 0x55688a);
const neon = themeColor('neon', 0x00e5ff);
@ -719,54 +731,22 @@ export class CombatScene extends Phaser.Scene {
})
.setOrigin(1, 0).setDepth(D);
// The armor panel (top-left): HULL bar + SHD bar + live numbers.
const pw = 330;
const ph = 96;
const px = 28;
const py = 28;
// The combat bar (bottom-center orb strip — js/ui/CombatBar.js, the
// SAME component the main game swaps in for the action deck): the
// hull + shield orbs, the eight skill seats (keys 1-8, the skills
// land with the real combat port — for now empty seats), and the
// energy orb. Its orbs are the readability layer's flash targets
// (the same shared flash the main game's bar gets).
this.combatBar = new CombatBar(this, {
onCast: () => {}, // no skills on the range yet — the seats are visual
});
// The control line (top-left — the bottom-center is the bar's now).
this.add
.graphics()
.setDepth(D)
.fillStyle(0x0a1120, 0.72)
.fillRoundedRect(px, py, pw, ph, 8)
.lineStyle(1, toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x22405f), 0.9)
.strokeRoundedRect(px, py, pw, ph, 8);
const lx = px + 16; // label x
const bx = px + 16; // bar x (left edge)
const bw = 210; // bar width
const numX = bx + bw + 14;
this.add.text(lx, py + 22, 'HULL', {
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D);
this.hullBarBg = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, 0x101a2c).setDepth(D);
this.hullBar = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, amber).setDepth(D);
this.hullText = this.add.text(numX, py + 40, '100', {
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
}).setOrigin(0, 0.5).setDepth(D);
this.add.text(lx, py + 62, 'SHD', {
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
}).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);
// The control line (bottom-center).
this.add
.text(w / 2, h - 12, hud.hints ?? '', {
.text(28, 22, hud.hints ?? '', {
fontFamily: bodyFont, fontSize: '12px', color: toCss(faint, '#55688a'), letterSpacing: 1,
})
.setOrigin(0.5, 1).setDepth(D);
.setOrigin(0, 0.5).setDepth(D);
// Center message (SIGNAL LOST / PAUSED).
this.msgText = this.add
@ -781,25 +761,17 @@ export class CombatScene extends Phaser.Scene {
.setOrigin(0.5).setAlpha(0).setDepth(D);
}
setBar(bar, frac) {
const w = Math.max(0, Math.min(1, frac)) * 210;
bar.width = w;
bar.x = 28 + 16 + w / 2; // left-anchored (rectangles are center-origin)
}
updateHud(time) {
const hullShown = Math.ceil(this.hull);
const shdShown = Math.ceil(this.shields);
if (this.lastHullShown !== hullShown) {
this.lastHullShown = hullShown;
this.hullText.setText(String(hullShown));
}
if (this.lastShdShown !== shdShown) {
this.lastShdShown = shdShown;
this.shdText.setText(String(shdShown));
}
this.setBar(this.hullBar, this.hull / this.maxHull);
this.setBar(this.shdBar, this.maxShields > 0 ? this.shields / this.maxShields : 0);
// The combat bar's orbs (the liquid drains as the values drop):
// hull + shield left, energy right.
this.combatBar?.setStats({
hull: this.hull,
maxHull: this.maxHull,
shields: this.shields,
maxShields: this.maxShields,
energy: this.energy,
maxEnergy: this.maxEnergy,
});
const killsShown = `KILLS ${this.kills}`;
if (this.lastKillsShown !== killsShown) {

View File

@ -958,7 +958,7 @@ export class GameScene extends Phaser.Scene {
const z = c?.zoom ?? 1;
return { x: (x - ox) * z, y: (y - oy) * z };
},
gaugeRect: (kind) => this.combatBar?.gaugeRect(kind) ?? null,
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
});
if (typeof window !== 'undefined') {
// Tuning handle: poke the pack / the armor / the bar from the console.

View File

@ -1,28 +1,69 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { fontStack } from '../utils/Theme.js';
import { canvasTexture } from '../utils/Textures.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const DOT_KEY = '__bar_orb_dot'; // the shared soft radial dot (orb gloss/shade)
const DEPTH = 50; // the deck's depth (ActionBar) — the bar swaps in its place
/** Nudge a 0xRRGGBB toward white (t = 0..1). Pure math. */
function mixWhite(n, t) {
const m = (c) => Math.round(c + (255 - c) * t);
return (m((n >> 16) & 255) << 16) | (m((n >> 8) & 255) << 8) | m(n & 255);
}
/**
* The COMBAT BAR — the main game's combat-mode action bar (data/combat.json
* → bar). SHOWN IN PLACE OF THE ACTION DECK while combat is live
* (GameScene swaps them: the deck hides, this takes its place, and back
* when the pack is wiped):
* The soft radial dot (white, 64px) the orb shading layers use — a
* white-tinted copy reads as highlight, a black tint as shadow. Cached
* by key, so the bar + any other consumer share one texture.
*/
function orbDotKey(scene) {
if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
canvasTexture(scene, DOT_KEY, 64, 64, (ctx, w, h) => {
const c = w / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.25, 'rgba(255,255,255,0.8)');
g.addColorStop(0.6, 'rgba(255,255,255,0.22)');
g.addColorStop(1.0, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
});
return DOT_KEY;
}
/**
* The COMBAT BAR — the combat-mode bar (data/combat.json → bar), SHOWN
* IN PLACE OF THE ACTION DECK while combat is live (GameScene swaps them:
* the deck hides, this takes its place, and back when the pack is
* wiped). The concept (assets/bar.png — a storyboard, NOT a color ref):
*
* [ SHD | HUL ] [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ] [ ENR ]
* (HULL orb + SHIELD orb) [1][2][3][4][5][6][7][8] (ENERGY orb)
*
* left — the player's vertical SHIELDS + HULL gauges
* left — the hull ORB (red) with the SHIELD orb (cyan, semi-
* transparent) concentric over it: the shield sits IN FRONT of
* the hull, so the red reads through the cyan.
* middle — the eight SKILL seats, wearing keys 1-8. The skills land
* later — for now they are empty seats (a press lands on
* o.onCast(index); the system's cast() is the no-op stub).
* right — the player's vertical ENERGY gauge (the skills' resource)
* right — the ENERGY orb (yellow — the skills' resource).
*
* BOTTOM-ALIGNED and CENTERED — sized to its contents, not stretched
* edge-to-edge like the deck. The orbs fill like a Diablo orb: the
* liquid column drains down as the value drops (setStats → _drawLiquid).
*
* Orb shading (so the circles read as ORBS, not 2D circles): a deep
* interior disc, the liquid column, a soft bottom shadow, a soft top
* gloss + small specular (additive), and the colored rim on top — all
* procedural, the soft layers from one shared soft-dot texture.
*
* Built the way ActionBar is (the deck's pattern, verified on this
* Giedi build): children created with scene.add.*, parented into the
* container with this.add(), each setScrollFactor(0) so they sit over
* the world at any zoom.
* the world at any zoom. Insertion order IS draw order — the shield
* orb's layers come after the hull orb's, so it sits in front.
*/
export class CombatBar extends Phaser.GameObjects.Container {
/**
@ -36,151 +77,266 @@ export class CombatBar extends Phaser.GameObjects.Container {
// container on the scene's display list or it never renders.
this.scene.add.existing(this);
this.setScrollFactor(0); // UI — pinned to the screen, not the world
this.setDepth(50); // the deck's depth
this.setDepth(DEPTH); // the deck's depth
this.onCast = typeof o?.onCast === 'function' ? o.onCast : null;
const bar = config.section('combat.bar', {});
const { width: W, height: H } = scene.scale;
const mL = Number(bar.margin?.left) || 20;
const mR = Number(bar.margin?.right) || 20;
const mB = Number(bar.margin?.bottom) || 12;
const barH = Number(bar.height) || 92;
const x0 = mL;
const y0 = H - mB - barH;
this.rect = { x: x0, y: y0, w: W - mL - mR, h: barH };
const c = bar.colors ?? {};
const panel = toColor(c.bg, themeColor('panel', 0x0b1322));
const edge = toColor(c.edge, themeColor('neon', 0x00e5ff));
const bg = toColor(c.bg, 0x070d18);
const bgAlpha = Number(c.bgAlpha) || 0.92;
const edge = toColor(c.edge, 0x22405f);
const hullC = toColor(c.hull, 0xff2d6f);
const shieldC = toColor(c.shield, 0x00e5ff);
const hullC = toColor(c.hull, 0x7dff8a);
const energyC = toColor(c.energy, 0xffd84d);
const slotBg = toColor(c.slotBg, 0x0b1322);
const slotEdge = toColor(c.slotBorder, 0x22405f);
const keyC = toColor(c.key, 0x5c7196);
const alpha = Number(bar.bgAlpha) || 0.92;
const slotBorder = toColor(c.slotBorder, 0x22405f);
const keyCss = toCss(c.key, '#5c7196');
// ---- the panel (the deck's footprint — margin/height mirror it) ----
this.add(this._rect(x0, y0, this.rect.w, barH, panel, alpha));
this.add(this._rect(x0, y0, this.rect.w, 2, edge, 0.4)); // top edge
// ---- geometry (the concept: centered strip, orbs at the ends) ----
const mB = Number(bar.margin?.bottom) || 26;
let stripH = Number(bar.strip?.height) || 64;
let edgePad = Number(bar.margin?.edge) || 10;
let orbGap = Number(bar.orbGap) || 24;
let R = Math.max(18, Number(bar.circle?.radius) || 46);
const sh = bar.circle?.shield ?? {};
const shScale = Number(sh.scale) || 1.12;
const shOX = Number(sh.offsetX) ?? 0;
const shOY = Number(sh.offsetY) ?? 0;
const shAlpha = Number(sh.fillAlpha) || 0.55;
const hullAlpha = Number(bar.circle?.hull?.fillAlpha) || 0.95;
const n = Math.max(1, Number(bar.slot?.count) || 8);
let S = Math.max(20, Number(bar.slot?.size) || 44);
let G = Number(bar.slot?.gap) || 10;
const keyPx = Math.max(8, Number(bar.slot?.keySize) || 12);
// ---- the gauges (vertical; fills grow UP from the bottom) ----------
const g = bar.gauge ?? {};
const gaugeW = Number(g.width) || 14;
const gaugeGap = Number(g.gap) || 8;
const pad = Number(g.pad) || 12;
const labelPx = Number(g.labelSize) || 11;
const gaugeH = barH - pad * 2 - labelPx - 4;
const gaugeTop = y0 + pad;
// Narrow viewports: scale the linear dims down (keep the anchor).
const fit = () => {
const sR = R * shScale;
const SW = n * S + (n - 1) * G;
return (R + shOX * R + sR) + 2 * orbGap + SW + R + 2 * edgePad;
};
let totalW = fit();
if (totalW > W - 16) {
const k = Math.max(0.5, (W - 16) / totalW);
stripH *= k; edgePad *= k; orbGap *= k; R *= k; S *= k; G *= k;
totalW = fit();
}
const mkGauge = (cx, color, label) => {
this.add(this._rect(cx - gaugeW / 2, gaugeTop, gaugeW, gaugeH, slotBg, 1));
// The fill: a bottom-anchored rect whose scaleY IS the level.
const fill = scene.add.rectangle(cx, gaugeTop + gaugeH, gaugeW - 4, gaugeH - 4, color, 1);
fill.setOrigin(0.5, 1);
fill.setScrollFactor(0);
this.add(fill);
const labelT = scene.add
.text(cx, y0 + barH - 11, label, {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${labelPx}px`,
color: toCss(themeColor('faint', 0x8391ad)),
letterSpacing: 1,
})
.setOrigin(0.5, 0.5)
.setScrollFactor(0);
this.add(labelT);
return { fill, frac: 1, track: { x: cx - gaugeW / 2, y: gaugeTop, w: gaugeW, h: gaugeH } };
const sR = R * shScale;
const SW = n * S + (n - 1) * G;
const x0 = (W - totalW) / 2;
const stripTop = H - mB - stripH;
const stripBottom = H - mB;
const cY = (stripTop + stripBottom) / 2;
const hull = { cx: x0 + edgePad + R, cy: cY, r: R };
const shield = { cx: hull.cx + shOX * R, cy: cY + shOY * R, r: sR };
const firstSlotL = shield.cx + shield.r + orbGap;
const eCx = firstSlotL + SW + orbGap + R;
const x1 = eCx + R + edgePad;
// ---- the strip (the panel the seats sit in) -----------------------
const panel = scene.add.graphics();
panel.fillStyle(bg, bgAlpha);
panel.fillRoundedRect(x0, stripTop, x1 - x0, stripH, Math.min(12, stripH * 0.2));
panel.lineStyle(2, edge, 0.55); // the top rule (the concept's double line)
panel.lineBetween(x0 + 12, stripTop + 1.5, x1 - 12, stripTop + 1.5);
panel.lineStyle(1.5, edge, 0.4);
panel.lineBetween(x0 + 12, stripBottom - 1.5, x1 - 12, stripBottom - 1.5);
panel.setScrollFactor(0).setDepth(DEPTH);
this.add(panel);
// ---- the orbs ------------------------------------------------------
const dot = orbDotKey(scene);
const track = bg; // the deep interior (the "empty" orb body)
const mkOrb = (cx, cy, r, color, fillAlpha, bodyAlpha = 0.94, shadeAlpha = 0.32) => {
// 1 — the body: a dark outer edge + the deep interior disc.
const base = scene.add.graphics();
base.lineStyle(3, 0x01040b, 0.65);
base.strokeCircle(cx, cy, r + 1);
base.fillStyle(track, bodyAlpha);
base.fillCircle(cx, cy, r);
// 2 — the liquid column (dynamic — setStats drains it).
const liquid = scene.add.graphics();
// 3 — the soft bottom shadow (weight, below the liquid line).
const shade = scene.add.image(cx, cy + r * 0.6, dot)
.setTint(0x000000).setAlpha(shadeAlpha)
.setScale((r * 2.3) / 64, (r * 1.15) / 64);
// 4 — the glass: a soft top gloss + a small specular (additive).
const gloss = scene.add.image(cx - r * 0.28, cy - r * 0.44, dot)
.setTint(0xffffff).setAlpha(0.15).setBlendMode(Phaser.BlendModes.ADD)
.setScale((r * 2.0) / 64, (r * 1.05) / 64);
const spec = scene.add.image(cx - r * 0.36, cy - r * 0.52, dot)
.setTint(0xffffff).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD)
.setScale((r * 0.7) / 64);
// 5 — the colored rim, on top of everything (crisp orb edge).
const rim = scene.add.graphics();
rim.lineStyle(2, color, 0.95);
rim.strokeCircle(cx, cy, r - 0.5);
rim.lineStyle(1, mixWhite(color, 0.4), 0.28); // rim light, just inside
rim.strokeCircle(cx, cy, r - 2.5);
for (const gobj of [base, liquid, shade, gloss, spec, rim]) {
gobj.setScrollFactor(0).setDepth(DEPTH);
this.add(gobj);
}
const orb = { cx, cy, r, color, fillAlpha, liquid, frac: 1 };
this._drawLiquid(orb);
return orb;
};
const leftX = x0 + pad + gaugeW / 2;
this.gShield = mkGauge(leftX, shieldC, 'SHD');
this.gHull = mkGauge(leftX + gaugeW + gaugeGap, hullC, 'HUL');
const rightX = x0 + this.rect.w - pad - gaugeW / 2;
this.gEnergy = mkGauge(rightX, energyC, 'ENR');
// The left cluster: HULL first, SHIELD on top — insertion order is
// draw order, so the shield's semi-transparent liquid sits OVER the
// hull's (the shield shares the hull's center and reads as the
// ship's bubble: shields in front of hull).
this.orbHull = mkOrb(hull.cx, hull.cy, hull.r, hullC, hullAlpha);
this.orbShield = mkOrb(shield.cx, shield.cy, shield.r, shieldC, shAlpha, 0.38, 0.16);
// ---- the skill seats (middle; keys 1-8; the skills land later) ----
const rad = Math.max(4, S * 0.18);
const slotG = scene.add.graphics();
for (let i = 0; i < n; i++) {
const lx = firstSlotL + i * (S + G);
const ly = cY - S / 2;
slotG.fillStyle(slotBg, 1);
slotG.fillRoundedRect(lx, ly, S, S, rad);
slotG.lineStyle(1.5, slotBorder, 0.9);
slotG.strokeRoundedRect(lx, ly, S, S, rad);
slotG.lineStyle(1.5, edge, 0.45); // the top lip (the deck's button idiom)
slotG.lineBetween(lx + rad, ly + 1, lx + S - rad, ly + 1);
}
slotG.setScrollFactor(0).setDepth(DEPTH);
this.add(slotG);
// ---- the skill seats (middle; keys 1-8; the skills land later) -----
const s = bar.slot ?? {};
const count = Math.max(0, Number(s.count) || 8);
const size = Number(s.size) || 64;
const gap = Number(s.gap) || 10;
const keyPx = Number(s.keySize) || 12;
const totalW = count * size + (count - 1) * gap;
let sx = W / 2 - totalW / 2 + size / 2;
const sy = y0 + barH / 2;
this.slots = [];
for (let i = 0; i < count; i++) {
const bg = scene.add.rectangle(sx - size / 2, sy - size / 2, size, size, slotBg, 1);
bg.setScrollFactor(0);
this.add(bg);
this.add(this._rect(sx - size / 2, sy - size / 2, size, 1, slotEdge, 0.9)); // top lip
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
if (this.onCast) this.onCast(i);
});
for (let i = 0; i < n; i++) {
const lx = firstSlotL + i * (S + G);
const ly = cY - S / 2;
const hit = scene.add.rectangle(lx + S / 2, cY, S, S, 0xffffff, 0);
hit.setScrollFactor(0).setDepth(DEPTH);
this.add(hit);
const keyT = scene.add
.text(sx - size / 2 + 6, sy - size / 2 + 5, String(i + 1), {
.text(lx + 6, ly + 5, String(i + 1), {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${keyPx}px`,
color: toCss(keyC),
color: keyCss,
})
.setOrigin(0, 0.5)
.setScrollFactor(0);
.setScrollFactor(0)
.setDepth(DEPTH);
this.add(keyT);
this.slots.push({ x: sx, bg, keyT });
sx += size + gap;
hit.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => {
if (this.onCast) this.onCast(i);
});
this.slots.push({ x: lx + S / 2, hit, keyT });
}
// ---- the energy orb (right end) -----------------------------------
this.orbEnergy = mkOrb(eCx, cY, R, energyC, 0.95);
// The bar's footprint (contains()'s click gate) — the orbs overhang
// the strip, so their bounds dominate.
const topY = Math.min(hull.cy - hull.r, shield.cy - shield.r);
const botY = Math.max(hull.cy + hull.r, shield.cy + shield.r);
this.rect = {
x: hull.cx - hull.r,
y: topY,
w: eCx + R - (hull.cx - hull.r),
h: botY - topY,
};
}
/**
* A solid rectangle, created on the scene (the scene.add.* factory is
* what puts it on the display list) — the bar's panel/chrome primitive.
* The caller parents it into the container with this.add(rect).
*/
_rect(x, y, w, h, color, alpha = 1) {
const r = this.scene.add.rectangle(x, y, w, h, color, alpha);
r.setScrollFactor(0);
return r;
}
/**
* Feed the gauges. Accepts a CombatSystem (uses its stats()) or a
* plain snapshot { hull, maxHull, shields, maxShields, energy, maxEnergy }.
* Feed the orbs. Accepts a CombatSystem (uses its stats()) or a plain
* snapshot { hull, maxHull, shields, maxShields, energy, maxEnergy }.
* Each orb's liquid drains as its value drops (Diablo-style) — redrawn
* only when the fraction actually changes.
*/
setStats(src) {
if (!src) return;
const s = typeof src.stats === 'function' ? src.stats() : src;
const frac = (v, m) => (m > 0 ? Math.max(0, Math.min(1, v / m)) : 0);
this._gauge(this.gShield, frac(s.shields, s.maxShields));
this._gauge(this.gHull, frac(s.hull, s.maxHull));
this._gauge(this.gEnergy, frac(s.energy, s.maxEnergy));
this._setOrb(this.orbHull, frac(s.hull, s.maxHull));
this._setOrb(this.orbShield, frac(s.shields, s.maxShields));
this._setOrb(this.orbEnergy, frac(s.energy, s.maxEnergy));
}
_gauge(g, f) {
g.frac = f;
g.fill.setScale(1, Math.max(0.001, f));
_setOrb(orb, f) {
if (!orb) return;
if (Math.abs(f - orb.frac) < 0.0004) return;
orb.frac = f;
this._drawLiquid(orb);
}
/**
* The liquid column: a bottom-anchored fill clipped to the circle (this
* Phaser build has no circle mask — the fill is drawn as thin horizontal
* strips, each clipped to the chord width at that height), with a
* brighter surface line where the liquid meets the air.
*/
_drawLiquid(orb) {
const g = orb.liquid;
g.clear();
const f = Math.max(0, Math.min(1, orb.frac));
if (f <= 0.002) return;
const { cx, cy, r } = orb;
const top = cy + r - 2 * r * f; // the surface (f=1 → the top of the orb)
const bottom = cy + r;
const step = 2;
g.fillStyle(orb.color, orb.fillAlpha);
for (let y = top; y < bottom; y += step) {
const yMid = Math.min(bottom - 0.5, y + step * 0.5);
const dy = yMid - cy;
const hw = Math.sqrt(Math.max(0, r * r - dy * dy));
if (hw <= 0.25) continue;
g.fillRect(cx - hw, y, hw * 2, Math.min(step, bottom - y) + 0.6);
}
if (f < 0.998) {
const hw = Math.sqrt(Math.max(0, r * r - (top - cy) ** 2));
g.lineStyle(2, mixWhite(orb.color, 0.55), 0.7); // the meniscus
g.lineBetween(cx - hw, top, cx + hw, top);
}
}
/** Is (px, py) inside the bar's footprint? (GameScene's click gate.) */
contains(px, py) {
const m = 6; // a little slop, like the deck's
const r = this.rect;
return (
px >= this.rect.x - m &&
px <= this.rect.x + this.rect.w + m &&
py >= this.rect.y - m &&
py <= this.rect.y + this.rect.h + m
px >= r.x - m &&
px <= r.x + r.w + m &&
py >= r.y - m &&
py <= r.y + r.h + m
);
}
/**
* 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'.
* The ORB shape (for the readability layer's hit-flash —
* js/combat/CombatHud.js blooms the orb that took the hit):
* 'hull', 'shield' or 'energy' → { type:'circle', cx, cy, r }.
*/
gaugeShape(kind) {
const o =
kind === 'hull' ? this.orbHull
: kind === 'shield' ? this.orbShield
: kind === 'energy' ? this.orbEnergy
: null;
return o ? { type: 'circle', cx: o.cx, cy: o.cy, r: o.r } : null;
}
/**
* The orb's TRACK rect (the old contract — CombatHud's rect fallback):
* the bounding square of 'hull'/'shield'/'energy'.
*/
gaugeRect(kind) {
const g = kind === 'hull' ? this.gHull : this.gShield;
return g && g.track ? g.track : null;
const s = this.gaugeShape(kind);
if (!s) return null;
return { x: s.cx - s.r, y: s.cy - s.r, w: s.r * 2, h: s.r * 2 };
}
/** Tear down (scene destroy) — the children go with the scene anyway. */