801 lines
34 KiB
JavaScript
801 lines
34 KiB
JavaScript
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();
|
||
}
|
||
}
|