import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { XpManager } from '../combat/XpManager.js'; import { toColor, toCss } from '../utils/Color.js'; import { fontStack } from '../utils/Theme.js'; import { canvasTexture } from '../utils/Textures.js'; import { barSpec, SKILL_ICON_KEY } from '../skills/SkillModel.js'; import { hexToInt } from '../skills/SkillEffects.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); } /** Thousands-separate an XP count (the XP row's numbers column). */ function fmtXp(n) { return Math.round(Math.max(0, Number(n) || 0)).toLocaleString('en-US'); } /** * 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) * ──────────────────────────────────────────────────────────────────── * ┌─ LV n ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ a / b XP ─┐ * └──────────── [1]'s left edge ──────────── [8]'s right edge ─────────┘ * * 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. * bottom — the XP bar (the combat level + bank — data/progression.json, * the xp gold): ONE full bar spanning the skill seats' length * (the number buttons' row), the summary embedded — "LV n" * on the left edge, the numbers on the right (banked XP over * the next level's cost; the banked total at the cap, where * the bar holds full with a soft glow and the xp keeps * counting — the seam for the post-cap currency). Each text * rides a soft dark chip (sized to the text) so the gold * reads over the gold fill as it does over the track; the * fill wears the orbs' glass idiom (sheen + meniscus edge). * A snapshot without xp state (the range's bar) hides the row. * middle — the eight SKILL seats, wearing keys 1-8. A seat is an ADDRESS: * the run's bindings live in the skill ledger (js/skills/SkillState.js), * a press lands on o.onCast(index) → GameScene.castSkillSeat → the * fight's cast(), and the fight answers every press with a reason. A * seat draws its skill's icon (assets/images/skills.png), a recharge * sweep with the seconds left, a dimmed "cannot afford it" veil and * an inert veil for a skill nobody has ranked; * unbound seats keep their reserved look. RIGHT-pressing a seat clears * it (making room), and while the skill tree is holding a skill up the * seats breathe and a press PLACES the skill instead of casting. * 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; this.onSeatPick = typeof o?.onSeatPick === 'function' ? o.onSeatPick : null; this.onSeatUnbind = typeof o?.onSeatUnbind === 'function' ? o.onSeatUnbind : null; // The assignment hand: set while the scene is waiting for a seat (the skill // tree's ASSIGN SEAT press). `awaitingSkillId` is data-only decoration. this.awaitingSeat = false; this.awaitingSkillId = 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); // The skill-seat FACE (data/skills.json → bar). The seat GEOMETRY is not // captured here — the narrow-viewport fit below rescales the seats, so // seatBox is taken from the final numbers next to the seats themselves. this._barSpec = barSpec(); // 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; // The XP row's band (the bar grows DOWNWARD from the orbs' row — // the panel's bottom edge stays put, the top grows): the separator // hairline + the bar's band (the bar is tall enough for its // embedded summary text). const sepBandH = 14; const xpBandH = 24; const panelBottom = H - mB; const panelTop = panelBottom - (stripH + sepBandH + xpBandH); const stripTop = panelTop; const stripBottom = panelTop + stripH; // the orbs' row (the original strip) const cY = (stripTop + stripBottom) / 2; const sepY = stripBottom + sepBandH / 2; const xpRowCY = stripBottom + sepBandH + xpBandH / 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 panel (the strip the seats sit in + the XP row below) ---- const panelH = panelBottom - panelTop; const panel = scene.add.graphics(); panel.fillStyle(bg, bgAlpha); panel.fillRoundedRect(x0, panelTop, x1 - x0, panelH, Math.min(12, panelH * 0.1)); panel.lineStyle(2, edge, 0.55); // the top rule (the concept's double line) panel.lineBetween(x0 + 12, panelTop + 1.5, x1 - 12, panelTop + 1.5); panel.lineStyle(1.5, edge, 0.4); panel.lineBetween(x0 + 12, panelBottom - 1.5, x1 - 12, panelBottom - 1.5); panel.lineStyle(1, edge, 0.3); // the separator (the orbs' row / the XP row) panel.lineBetween(x0 + 12, sepY, x1 - 12, sepY); 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); // The seat geometry the overlays draw into — captured AFTER the narrow- // viewport fit, so the veils line up with the frames at any width. this.seatBox = { size: S, gap: G, left: firstSlotL, cy: cY, rad }; 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 seats' SKILL face ------------------------------------------ // Which STATE a seat is in is the FIGHT's answer (js/combat/CombatSystem.js // → skillSeats(): ready / recharging / not affordable / inert / empty); this // layer only draws what each one looks like, and every colour, size and pulse // rate is data-owned (data/skills.json → bar). Draw order per seat: the icon, // a VEIL over it (an unranked skill is buried, a too-expensive one dimmed), // the recharge SWEEP with the SECONDS left printed over that. An empty seat // draws nothing here — its reserved frame // (above) with its key number IS the empty look. this.seatArt = []; for (let i = 0; i < n; i++) { const lx = firstSlotL + i * (S + G); const ly = cY - S / 2; // The icon is created LAZILY on the first bound seat (an image built on a // sheet that never loaded wears Phaser's default texture — a stray white // square in the bar). const veil = scene.add.graphics().setScrollFactor(0).setDepth(DEPTH); const sweep = scene.add.graphics().setScrollFactor(0).setDepth(DEPTH); const edge = scene.add.graphics().setScrollFactor(0).setDepth(DEPTH); const secs = scene.add .text(lx + S / 2, cY, '', { fontFamily: fontStack('body', FONT_FALLBACK), fontSize: `${this._barSpec.secondsTextPx}px`, fontStyle: 'bold', color: this._barSpec.states.secondsColor, stroke: '#02070d', strokeThickness: 3, }) .setOrigin(0.5, 0.5) .setScrollFactor(0) .setDepth(DEPTH + 1) .setVisible(false); this.add(veil); this.add(sweep); this.add(edge); this.add(secs); this.seatArt.push({ lx, ly, veil, sweep, edge, secs, state: 'empty', // what the seat is drawing (see _drawSeat) icon: null, // created on the first seat that carries a skill sig: '', // last draw signature — unchanged seats are skipped }); } 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 }); // A seat's press: while the scene is AWAITING A SEAT (the skill tree handed // a skill up to be placed) the press answers the assignment instead of // casting; a RIGHT press clears the seat (making room for another skill). hit.on('pointerdown', (p) => { if (this.awaitingSeat) { if (this.onSeatPick) this.onSeatPick(i); return; } if (p?.rightButtonDown?.() || p?.event?.button === 2) { if (this.onSeatUnbind) this.onSeatUnbind(i); return; } 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 XP row (below the orbs' row — the level + the bank) ----- // The combat level + the XP bank (data/progression.json — the row is // the xp gold, the progression currency's color). ONE full bar // spanning the skill seats' length (the number buttons' row), the // summary embedded in it: // // LV n ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ a / b XP // // "LV n" rides the bar's left edge, the numbers the right — banked // XP over the next level's cost; at the cap the bar holds full (a // soft glow) and the numbers read the banked total (the xp keeps // counting — the seam for the post-cap currency, when the skills // land). Each sits on a soft dark chip (sized to the text, redrawn // when it changes) so the gold reads over the gold fill as it does // over the track. The fill wears the orbs' glass idiom (sheen + the // meniscus line at its edge). const xpGold = toColor(c.energy, 0xffd84d); const xpCss = toCss(c.energy, '#ffd84d'); const _cap = new XpManager(); // data-driven — the level-1 cost const barX = firstSlotL; // the seats' left edge — the bar spans their row const barW = SW; // the number buttons' full length const barH = 18; // tall enough for the embedded summary text const barY = xpRowCY - barH / 2; const xpTrack = scene.add.graphics(); xpTrack.fillStyle(0x0a121d, 0.9); // the track (a step darker than the panel) xpTrack.fillRoundedRect(barX, barY, barW, barH, barH / 2); xpTrack.lineStyle(1, xpGold, 0.5); // the gold keyline xpTrack.strokeRoundedRect(barX + 0.5, barY + 0.5, barW - 1, barH - 1, (barH - 1) / 2); xpTrack.setScrollFactor(0).setDepth(DEPTH).setVisible(false); // hidden until a snapshot carries xp this.add(xpTrack); const xpFill = scene.add.graphics().setScrollFactor(0).setDepth(DEPTH).setVisible(false); this.add(xpFill); const xpLabelChip = scene.add.graphics() .setScrollFactor(0).setDepth(DEPTH).setVisible(false); xpLabelChip.setPosition(barX + 10, xpRowCY); this.add(xpLabelChip); const xpLabel = scene.add .text(barX + 10, xpRowCY, 'LV 0', { fontFamily: fontStack('body', FONT_FALLBACK), fontSize: '13px', fontStyle: 'bold', color: xpCss, stroke: '#02131c', strokeThickness: 2.5, }) .setOrigin(0, 0.5) .setScrollFactor(0) .setDepth(DEPTH) .setVisible(false); this.add(xpLabel); this._refreshXpChip(xpLabelChip, xpLabel); const startNums = `0 / ${fmtXp(_cap.xpForLevel(1))} XP`; const xpNumbersChip = scene.add.graphics() .setScrollFactor(0).setDepth(DEPTH).setVisible(false); xpNumbersChip.setPosition(barX + barW - 10, xpRowCY); this.add(xpNumbersChip); const xpNumbers = scene.add .text(barX + barW - 10, xpRowCY, startNums, { fontFamily: fontStack('body', FONT_FALLBACK), fontSize: '11px', color: xpCss, stroke: '#02131c', strokeThickness: 2, }) .setOrigin(1, 0.5) .setScrollFactor(0) .setDepth(DEPTH) .setVisible(false); this.add(xpNumbers); this._refreshXpChip(xpNumbersChip, xpNumbers); this.xpBar = { x: barX, y: barY, w: barW, h: barH, color: xpGold, fill: xpFill, frac: -1 }; this.xpLabel = xpLabel; this.xpLabelChip = xpLabelChip; this.xpNumbers = xpNumbers; this.xpNumbersChip = xpNumbersChip; this.xpTrack = xpTrack; this._xpLabel = 'LV 0'; this._xpNumbers = startNums; this._xpShown = false; this._xpMax = false; // The bar's footprint (contains()'s click gate) — the orbs overhang // the panel, so their bounds dominate (the XP row below is // display-only — no clicks to gate). 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)); // The SKILL seats (data/skills.json → bar for the look, CombatSystem → // skillSeats() for the state). A snapshot with no `seats` (the range's bare // armor/energy feed) leaves whatever the seats last drew alone. this._setSkillSeats(s.seats); // The XP bar (the level + the bank — a snapshot without xp state // hides it, so a bar fed a bare armor/energy snapshot — the range — // keeps its old face). if (this.xpLabel) { const row = [this.xpLabel, this.xpLabelChip, this.xpNumbers, this.xpNumbersChip, this.xpTrack, this.xpBar?.fill]; if (s.xp) { if (!this._xpShown) { this._xpShown = true; for (const o of row) o?.setVisible(true); } const { level, xp, xpForNext, isMax } = s.xp; const label = isMax ? `LV ${level} MAX` : `LV ${level}`; if (label !== this._xpLabel) { this._xpLabel = label; this.xpLabel.setText(label); this._refreshXpChip(this.xpLabelChip, this.xpLabel); } const numbers = isMax ? `XP ${fmtXp(xp)} banked` : `${fmtXp(xp)} / ${fmtXp(xpForNext)} XP`; if (numbers !== this._xpNumbers) { this._xpNumbers = numbers; this.xpNumbers.setText(numbers); this._refreshXpChip(this.xpNumbersChip, this.xpNumbers); } this._drawXpBar(isMax ? 1 : (s.xp.progress ?? 0), isMax); } else if (this._xpShown) { this._xpShown = false; for (const o of row) o?.setVisible(false); } } } // ------------------------------------------------------ the skill seats /** * Draw the eight SKILL seats from the fight's seat rows (CombatSystem → * skillSeats(), fed by stats()). Cheap by design: a seat redraws only when its * draw SIGNATURE changes (state + rank + the sweep's quantum + the whole seconds * printed), so an idle bar touches nothing and eight seats cost nothing. */ _setSkillSeats(seats) { if (!this.seatArt?.length || !Array.isArray(seats)) return; const t = this.scene?.time?.now ?? 0; for (let i = 0; i < this.seatArt.length; i++) { // The assignment pulse needs a redraw every frame; everything else is // signature-gated (an idle bar touches nothing). if (this.awaitingSeat) this.seatArt[i].sig = ''; this._drawSeat(this.seatArt[i], seats[i] ?? null, t); } } /** * One seat. `row` is the fight's answer for that seat (null = no fight, so an * empty reserved seat). The precedence reads like the cast pipeline's: an * unranked skill is BURIED (it holds the seat but is not live), a recharging one * wears the sweep, and a skill the orb cannot pay for is dimmed. */ _drawSeat(art, row, t) { if (!art) return; const st = this._barSpec.states; const awaiting = !!this.awaitingSeat; const bound = !!(row && row.id); const cooling = bound && (row.cooldownLeft ?? 0) > 0; // The sweep quantises at ~1% so a recharge animates without a redraw per ms. const frac = bound ? Math.round(Math.max(0, Math.min(1, Number(row.cooldownFrac) ?? 1)) * 100) / 100 : 1; const secs = cooling ? String(Math.max(1, Math.ceil(row.cooldownLeft))) : ''; const sig = [ bound ? row.id : '-', row?.rank ?? 0, row?.inert ? 'i' : '', row?.affordable ? 'a' : '', cooling ? 'c' : '', frac, secs, awaiting ? `w${this.awaitingSkillId ?? ''}` : '', ].join('|'); if (sig === art.sig) return; art.sig = sig; // The state this seat is DRAWING (the same read the veil/sweep below encodes, // named for the human/debug/test reader: 'empty' | 'inert' | 'cooldown' | // 'noEnergy' | 'ready' | 'busy'). art.state = !bound ? 'empty' : row.inert ? 'inert' : cooling ? 'cooldown' : !row.affordable ? 'noEnergy' : row.ready ? 'ready' : 'busy'; const { lx, ly, veil, sweep, edge } = art; const S = this.seatBox.size; const rad = this.seatBox.rad; const boxIn = [lx + 1.5, ly + 1.5, S - 3, S - 3]; // 1 — the icon (lazy: a seat that has never held a skill has no image). if (bound && this._iconFor(art, row)) { art.icon.setVisible(true).setPosition(lx + S / 2, this.seatBox.cy); } else if (art.icon) { art.icon.setVisible(false); } // 2 — the veil over it. veil.clear(); if (bound) { const [color, alpha] = row.inert ? [st.inertVeil, st.inertVeilAlpha] : cooling ? [st.cooldownVeil, st.cooldownVeilAlpha] : !row.affordable ? [st.noEnergyVeil, st.noEnergyVeilAlpha] : [null, 0]; if (color) { veil.fillStyle(hexToInt(color, 0x050a13), alpha); veil.fillRoundedRect(...boxIn, rad); } } // 3 — the recharge sweep: the part of the clock STILL WAITING, unwinding // clockwise from 12 as the seconds run out (the icon re-emerges behind it). sweep.clear(); if (cooling && frac < 1) { const cx = lx + S / 2; const cy = this.seatBox.cy; const rr = S * 0.46; const a0 = -Math.PI / 2; const a1 = a0 + Math.PI * 2 * (1 - frac); const steps = Math.max(2, Math.ceil(24 * (1 - frac))); const pts = [{ x: cx, y: cy }]; for (let k = 0; k <= steps; k++) { const a = a0 + ((a1 - a0) * k) / steps; pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr }); } sweep.fillStyle(hexToInt(st.sweepColor, 0x9fd8ff), st.sweepAlpha); sweep.fillPoints(pts, true); } // 4 — the outline: the assignment pulse (every seat is a candidate, so they // all breathe), else a live-and-charged seat's ready edge. edge.clear(); if (awaiting) { const pulse = 0.5 + 0.5 * Math.sin((t / 1000) * Math.PI * 2 * this._barSpec.assignPulseHz); edge.lineStyle(2, hexToInt(st.assignEdge, 0xffd84d), 0.35 + 0.6 * pulse); edge.strokeRoundedRect(...boxIn, rad); } else if (bound && row.ready) { edge.lineStyle(1.5, hexToInt(st.readyEdge, 0x7ce8ff), 0.85); edge.strokeRoundedRect(...boxIn, rad); } // 5 — the printed seconds. art.secs.setVisible(cooling).setText(secs); } /** The seat's icon image, made on first use (the sheet is loaded once). */ _iconFor(art, row) { if (art.icon) return art.icon; const frame = Number(row?.frame); if (!Number.isFinite(frame) || !this.scene?.textures?.exists(SKILL_ICON_KEY)) return null; const S = this.seatBox.size; const size = S * this._barSpec.iconScale; const f = Math.max(0, Math.round(frame)); // Scale on the FRAME's own width (the sheet is 128×128 today; a future sheet // of another cell size still fills the seat). const fw = this.scene.textures.get(SKILL_ICON_KEY)?.get?.(f)?.width || 0; art.icon = this.scene.add .image(art.lx + S / 2, this.seatBox.cy, SKILL_ICON_KEY, f) .setScale(size / (fw > 0 ? fw : size)) .setScrollFactor(0) .setDepth(DEPTH); this.add(art.icon); // The icon is made LAZILY (mid-frame, on the first seat that needs it), so it // lands on top of the seat's state layers and an opaque sheet would bury the // veil, the sweep and the seconds. Put those back above it — // veil → sweep → edge → seconds, the order the data describes // (data/skills.json → bar._comment). The icon never moves again afterwards. for (const go of [art.veil, art.sweep, art.edge, art.secs]) { if (!go) continue; if (typeof this.bringToTop === 'function') this.bringToTop(go); else { const i = this.list.indexOf(go); if (i >= 0) { this.list.splice(i, 1); this.list.push(go); } } } return art.icon; } /** * The ASSIGNMENT hand: the skill tree handed a skill up (its ASSIGN SEAT * press), so every seat breathes and a press ANSWERS THE PLACEMENT instead of * casting. `id` is decoration only — the scene owns the ledger that gets bound. */ setAwaitingSeat(o = {}) { const on = !!o.on; this.awaitingSeat = on; this.awaitingSkillId = on ? (o.id ?? null) : null; for (const art of this.seatArt ?? []) art.sig = ''; // force one redraw (and the pulse's first frame) return this; } /** * The embedded summary's dark chip — sized to the text it sits under, * centered on the text's position (the text's own anchor is * irrelevant — the chip is symmetric). */ _refreshXpChip(chip, t) { if (!chip || !t) return; chip.clear(); chip.fillStyle(0x02131c, 0.55); chip.fillRoundedRect(-t.width / 2 - 5, -t.height / 2 - 2.5, t.width + 10, t.height + 5, 4); } /** * The XP bar's fill (epsilon-guarded, like the orbs' liquids): the * gold column, the glass sheen, the meniscus line at the fill's edge — * and at the cap a soft outer glow (the bar is full and holding). */ _drawXpBar(f, isMax) { const b = this.xpBar; if (!b) return; f = Math.max(0, Math.min(1, Number(f) || 0)); if (Math.abs(f - b.frac) < 0.002 && isMax === this._xpMax) return; b.frac = f; this._xpMax = isMax; const g = b.fill; g.clear(); if (f <= 0.004) return; const x = b.x + 1.5; const y = b.y + 1.5; const h = b.h - 3; const fw = Math.max(h, (b.w - 3) * f); g.fillStyle(b.color, 0.95); g.fillRoundedRect(x, y, fw, h, h / 2); g.fillStyle(0xffffff, 0.2); // the sheen (the orbs' glass, top stripe) g.fillRoundedRect(x + 2, y + 1, Math.max(1, fw - 4), Math.max(1.2, h * 0.3), 1.2); if (f < 0.999) { g.fillStyle(0xffffff, 0.55); // the meniscus — the fill's leading edge g.fillRoundedRect(x + fw - 2.5, y, 2.5, h, 1.25); } if (isMax) { g.fillStyle(b.color, 0.22); // the cap: a soft glow around the full bar g.fillRoundedRect(x - 1.5, y - 1.5, fw + 3, h + 3, (h + 3) / 2); } } _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; this.onSeatPick = null; this.onSeatUnbind = null; for (const art of this.seatArt ?? []) { art.icon = null; art.veil = art.sweep = art.edge = art.secs = null; } this.seatArt = null; this.xpLabel = null; this.xpLabelChip = null; this.xpNumbers = null; this.xpNumbersChip = null; this.xpTrack = null; this.xpBar = null; super.destroy(); } }