import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor, toCss } from '../utils/Color.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 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): * * (HULL orb + SHIELD orb) [1][2][3][4][5][6][7][8] (ENERGY orb) * * 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 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. 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 { /** * @param {Phaser.Scene} scene a scale/texture-equipped scene (GameScene) * @param {object} [o] * onCast (index) => {} — a skill seat was pressed (index 0-7) */ constructor(scene, o = {}) { super(scene, 0, 0); // v4 quirk (same note as ActionBar): register the directly-built // 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(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 c = bar.colors ?? {}; 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 energyC = toColor(c.energy, 0xffd84d); const slotBg = toColor(c.slotBg, 0x0b1322); const slotBorder = toColor(c.slotBorder, 0x22405f); const keyCss = toCss(c.key, '#5c7196'); // ---- 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); // 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 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; }; // 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); this.slots = []; 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(lx + 6, ly + 5, String(i + 1), { fontFamily: fontStack('body', FONT_FALLBACK), fontSize: `${keyPx}px`, color: keyCss, }) .setOrigin(0, 0.5) .setScrollFactor(0) .setDepth(DEPTH); this.add(keyT); 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, }; } /** * 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._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)); } _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 >= r.x - m && px <= r.x + r.w + m && py >= r.y - m && py <= r.y + r.h + m ); } /** * 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 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. */ destroy() { this.onCast = null; super.destroy(); } }