diff --git a/assets/bar.png b/assets/bar.png new file mode 100644 index 0000000..e2b2745 Binary files /dev/null and b/assets/bar.png differ diff --git a/data/combat.json b/data/combat.json index 051e557..a748bf6 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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 } } } diff --git a/dev/shots/combat-main.png b/dev/shots/combat-main.png index 7a97b86..f7695fd 100644 Binary files a/dev/shots/combat-main.png and b/dev/shots/combat-main.png differ diff --git a/dev/shots/combat-range.png b/dev/shots/combat-range.png index 941c6a8..e40a83a 100644 Binary files a/dev/shots/combat-range.png and b/dev/shots/combat-range.png differ diff --git a/js/combat/CombatHud.js b/js/combat/CombatHud.js index f655347..ac3a574 100644 --- a/js/combat/CombatHud.js +++ b/js/combat/CombatHud.js @@ -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; } } diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index 376001c..7775a42 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -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) { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 82ef5b4..ac61dd3 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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. diff --git a/js/ui/CombatBar.js b/js/ui/CombatBar.js index d697768..692d442 100644 --- a/js/ui/CombatBar.js +++ b/js/ui/CombatBar.js @@ -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. */