orbit/js/ui/CombatBar.js

801 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}