348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
|
import { config } from '../config/Config.js';
|
|
import { toColor, toCss } from '../utils/Color.js';
|
|
import { fontStack } from '../utils/Theme.js';
|
|
import { canvasTexture } from '../utils/Textures.js';
|
|
|
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|
const DOT_KEY = '__bar_orb_dot'; // the shared soft radial dot (orb gloss/shade)
|
|
const DEPTH = 50; // the deck's depth (ActionBar) — the bar swaps in its place
|
|
|
|
/** Nudge a 0xRRGGBB toward white (t = 0..1). Pure math. */
|
|
function mixWhite(n, t) {
|
|
const m = (c) => Math.round(c + (255 - c) * t);
|
|
return (m((n >> 16) & 255) << 16) | (m((n >> 8) & 255) << 8) | m(n & 255);
|
|
}
|
|
|
|
/**
|
|
* The soft radial dot (white, 64px) the orb shading layers use — a
|
|
* white-tinted copy reads as highlight, a black tint as shadow. Cached
|
|
* by key, so the bar + any other consumer share one texture.
|
|
*/
|
|
function orbDotKey(scene) {
|
|
if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
|
|
canvasTexture(scene, DOT_KEY, 64, 64, (ctx, w, h) => {
|
|
const c = w / 2;
|
|
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
|
g.addColorStop(0.0, 'rgba(255,255,255,1)');
|
|
g.addColorStop(0.25, 'rgba(255,255,255,0.8)');
|
|
g.addColorStop(0.6, 'rgba(255,255,255,0.22)');
|
|
g.addColorStop(1.0, 'rgba(255,255,255,0)');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, w, h);
|
|
});
|
|
return DOT_KEY;
|
|
}
|
|
|
|
/**
|
|
* The COMBAT BAR — the combat-mode bar (data/combat.json → bar), SHOWN
|
|
* IN PLACE OF THE ACTION DECK while combat is live (GameScene swaps them:
|
|
* the deck hides, this takes its place, and back when the pack is
|
|
* wiped). The concept (assets/bar.png — a storyboard, NOT a color ref):
|
|
*
|
|
* (HULL orb + SHIELD orb) [1][2][3][4][5][6][7][8] (ENERGY orb)
|
|
*
|
|
* left — the hull ORB (red) with the SHIELD orb (cyan, semi-
|
|
* transparent) concentric over it: the shield sits IN FRONT of
|
|
* the hull, so the red reads through the cyan.
|
|
* middle — the eight SKILL seats, wearing keys 1-8. The skills land
|
|
* later — for now they are empty seats (a press lands on
|
|
* o.onCast(index); the system's cast() is the no-op stub).
|
|
* right — the ENERGY orb (yellow — the skills' resource).
|
|
*
|
|
* BOTTOM-ALIGNED and CENTERED — sized to its contents, not stretched
|
|
* edge-to-edge like the deck. The orbs fill like a Diablo orb: the
|
|
* liquid column drains down as the value drops (setStats → _drawLiquid).
|
|
*
|
|
* Orb shading (so the circles read as ORBS, not 2D circles): a deep
|
|
* interior disc, the liquid column, a soft bottom shadow, a soft top
|
|
* gloss + small specular (additive), and the colored rim on top — all
|
|
* procedural, the soft layers from one shared soft-dot texture.
|
|
*
|
|
* Built the way ActionBar is (the deck's pattern, verified on this
|
|
* Giedi build): children created with scene.add.*, parented into the
|
|
* container with this.add(), each setScrollFactor(0) so they sit over
|
|
* the world at any zoom. Insertion order IS draw order — the shield
|
|
* orb's layers come after the hull orb's, so it sits in front.
|
|
*/
|
|
export class CombatBar extends Phaser.GameObjects.Container {
|
|
/**
|
|
* @param {Phaser.Scene} scene a scale/texture-equipped scene (GameScene)
|
|
* @param {object} [o]
|
|
* onCast (index) => {} — a skill seat was pressed (index 0-7)
|
|
*/
|
|
constructor(scene, o = {}) {
|
|
super(scene, 0, 0);
|
|
// v4 quirk (same note as ActionBar): register the directly-built
|
|
// container on the scene's display list or it never renders.
|
|
this.scene.add.existing(this);
|
|
this.setScrollFactor(0); // UI — pinned to the screen, not the world
|
|
this.setDepth(DEPTH); // the deck's depth
|
|
|
|
this.onCast = typeof o?.onCast === 'function' ? o.onCast : null;
|
|
|
|
const bar = config.section('combat.bar', {});
|
|
const { width: W, height: H } = scene.scale;
|
|
|
|
const c = bar.colors ?? {};
|
|
const bg = toColor(c.bg, 0x070d18);
|
|
const bgAlpha = Number(c.bgAlpha) || 0.92;
|
|
const edge = toColor(c.edge, 0x22405f);
|
|
const hullC = toColor(c.hull, 0xff2d6f);
|
|
const shieldC = toColor(c.shield, 0x00e5ff);
|
|
const energyC = toColor(c.energy, 0xffd84d);
|
|
const slotBg = toColor(c.slotBg, 0x0b1322);
|
|
const slotBorder = toColor(c.slotBorder, 0x22405f);
|
|
const keyCss = toCss(c.key, '#5c7196');
|
|
|
|
// ---- geometry (the concept: centered strip, orbs at the ends) ----
|
|
const mB = Number(bar.margin?.bottom) || 26;
|
|
let stripH = Number(bar.strip?.height) || 64;
|
|
let edgePad = Number(bar.margin?.edge) || 10;
|
|
let orbGap = Number(bar.orbGap) || 24;
|
|
let R = Math.max(18, Number(bar.circle?.radius) || 46);
|
|
const sh = bar.circle?.shield ?? {};
|
|
const shScale = Number(sh.scale) || 1.12;
|
|
const shOX = Number(sh.offsetX) ?? 0;
|
|
const shOY = Number(sh.offsetY) ?? 0;
|
|
const shAlpha = Number(sh.fillAlpha) || 0.55;
|
|
const hullAlpha = Number(bar.circle?.hull?.fillAlpha) || 0.95;
|
|
const n = Math.max(1, Number(bar.slot?.count) || 8);
|
|
let S = Math.max(20, Number(bar.slot?.size) || 44);
|
|
let G = Number(bar.slot?.gap) || 10;
|
|
const keyPx = Math.max(8, Number(bar.slot?.keySize) || 12);
|
|
|
|
// Narrow viewports: scale the linear dims down (keep the anchor).
|
|
const fit = () => {
|
|
const sR = R * shScale;
|
|
const SW = n * S + (n - 1) * G;
|
|
return (R + shOX * R + sR) + 2 * orbGap + SW + R + 2 * edgePad;
|
|
};
|
|
let totalW = fit();
|
|
if (totalW > W - 16) {
|
|
const k = Math.max(0.5, (W - 16) / totalW);
|
|
stripH *= k; edgePad *= k; orbGap *= k; R *= k; S *= k; G *= k;
|
|
totalW = fit();
|
|
}
|
|
|
|
const sR = R * shScale;
|
|
const SW = n * S + (n - 1) * G;
|
|
const x0 = (W - totalW) / 2;
|
|
const stripTop = H - mB - stripH;
|
|
const stripBottom = H - mB;
|
|
const cY = (stripTop + stripBottom) / 2;
|
|
|
|
const hull = { cx: x0 + edgePad + R, cy: cY, r: R };
|
|
const shield = { cx: hull.cx + shOX * R, cy: cY + shOY * R, r: sR };
|
|
const firstSlotL = shield.cx + shield.r + orbGap;
|
|
const eCx = firstSlotL + SW + orbGap + R;
|
|
const x1 = eCx + R + edgePad;
|
|
|
|
// ---- the strip (the panel the seats sit in) -----------------------
|
|
const panel = scene.add.graphics();
|
|
panel.fillStyle(bg, bgAlpha);
|
|
panel.fillRoundedRect(x0, stripTop, x1 - x0, stripH, Math.min(12, stripH * 0.2));
|
|
panel.lineStyle(2, edge, 0.55); // the top rule (the concept's double line)
|
|
panel.lineBetween(x0 + 12, stripTop + 1.5, x1 - 12, stripTop + 1.5);
|
|
panel.lineStyle(1.5, edge, 0.4);
|
|
panel.lineBetween(x0 + 12, stripBottom - 1.5, x1 - 12, stripBottom - 1.5);
|
|
panel.setScrollFactor(0).setDepth(DEPTH);
|
|
this.add(panel);
|
|
|
|
// ---- the orbs ------------------------------------------------------
|
|
const dot = orbDotKey(scene);
|
|
const track = bg; // the deep interior (the "empty" orb body)
|
|
|
|
const mkOrb = (cx, cy, r, color, fillAlpha, bodyAlpha = 0.94, shadeAlpha = 0.32) => {
|
|
// 1 — the body: a dark outer edge + the deep interior disc.
|
|
const base = scene.add.graphics();
|
|
base.lineStyle(3, 0x01040b, 0.65);
|
|
base.strokeCircle(cx, cy, r + 1);
|
|
base.fillStyle(track, bodyAlpha);
|
|
base.fillCircle(cx, cy, r);
|
|
// 2 — the liquid column (dynamic — setStats drains it).
|
|
const liquid = scene.add.graphics();
|
|
// 3 — the soft bottom shadow (weight, below the liquid line).
|
|
const shade = scene.add.image(cx, cy + r * 0.6, dot)
|
|
.setTint(0x000000).setAlpha(shadeAlpha)
|
|
.setScale((r * 2.3) / 64, (r * 1.15) / 64);
|
|
// 4 — the glass: a soft top gloss + a small specular (additive).
|
|
const gloss = scene.add.image(cx - r * 0.28, cy - r * 0.44, dot)
|
|
.setTint(0xffffff).setAlpha(0.15).setBlendMode(Phaser.BlendModes.ADD)
|
|
.setScale((r * 2.0) / 64, (r * 1.05) / 64);
|
|
const spec = scene.add.image(cx - r * 0.36, cy - r * 0.52, dot)
|
|
.setTint(0xffffff).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD)
|
|
.setScale((r * 0.7) / 64);
|
|
// 5 — the colored rim, on top of everything (crisp orb edge).
|
|
const rim = scene.add.graphics();
|
|
rim.lineStyle(2, color, 0.95);
|
|
rim.strokeCircle(cx, cy, r - 0.5);
|
|
rim.lineStyle(1, mixWhite(color, 0.4), 0.28); // rim light, just inside
|
|
rim.strokeCircle(cx, cy, r - 2.5);
|
|
|
|
for (const gobj of [base, liquid, shade, gloss, spec, rim]) {
|
|
gobj.setScrollFactor(0).setDepth(DEPTH);
|
|
this.add(gobj);
|
|
}
|
|
const orb = { cx, cy, r, color, fillAlpha, liquid, frac: 1 };
|
|
this._drawLiquid(orb);
|
|
return orb;
|
|
};
|
|
|
|
// The left cluster: HULL first, SHIELD on top — insertion order is
|
|
// draw order, so the shield's semi-transparent liquid sits OVER the
|
|
// hull's (the shield shares the hull's center and reads as the
|
|
// ship's bubble: shields in front of hull).
|
|
this.orbHull = mkOrb(hull.cx, hull.cy, hull.r, hullC, hullAlpha);
|
|
this.orbShield = mkOrb(shield.cx, shield.cy, shield.r, shieldC, shAlpha, 0.38, 0.16);
|
|
|
|
// ---- the skill seats (middle; keys 1-8; the skills land later) ----
|
|
const rad = Math.max(4, S * 0.18);
|
|
const slotG = scene.add.graphics();
|
|
for (let i = 0; i < n; i++) {
|
|
const lx = firstSlotL + i * (S + G);
|
|
const ly = cY - S / 2;
|
|
slotG.fillStyle(slotBg, 1);
|
|
slotG.fillRoundedRect(lx, ly, S, S, rad);
|
|
slotG.lineStyle(1.5, slotBorder, 0.9);
|
|
slotG.strokeRoundedRect(lx, ly, S, S, rad);
|
|
slotG.lineStyle(1.5, edge, 0.45); // the top lip (the deck's button idiom)
|
|
slotG.lineBetween(lx + rad, ly + 1, lx + S - rad, ly + 1);
|
|
}
|
|
slotG.setScrollFactor(0).setDepth(DEPTH);
|
|
this.add(slotG);
|
|
|
|
this.slots = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const lx = firstSlotL + i * (S + G);
|
|
const ly = cY - S / 2;
|
|
const hit = scene.add.rectangle(lx + S / 2, cY, S, S, 0xffffff, 0);
|
|
hit.setScrollFactor(0).setDepth(DEPTH);
|
|
this.add(hit);
|
|
const keyT = scene.add
|
|
.text(lx + 6, ly + 5, String(i + 1), {
|
|
fontFamily: fontStack('body', FONT_FALLBACK),
|
|
fontSize: `${keyPx}px`,
|
|
color: keyCss,
|
|
})
|
|
.setOrigin(0, 0.5)
|
|
.setScrollFactor(0)
|
|
.setDepth(DEPTH);
|
|
this.add(keyT);
|
|
hit.setInteractive({ useHandCursor: true });
|
|
hit.on('pointerdown', () => {
|
|
if (this.onCast) this.onCast(i);
|
|
});
|
|
this.slots.push({ x: lx + S / 2, hit, keyT });
|
|
}
|
|
|
|
// ---- the energy orb (right end) -----------------------------------
|
|
this.orbEnergy = mkOrb(eCx, cY, R, energyC, 0.95);
|
|
|
|
// The bar's footprint (contains()'s click gate) — the orbs overhang
|
|
// the strip, so their bounds dominate.
|
|
const topY = Math.min(hull.cy - hull.r, shield.cy - shield.r);
|
|
const botY = Math.max(hull.cy + hull.r, shield.cy + shield.r);
|
|
this.rect = {
|
|
x: hull.cx - hull.r,
|
|
y: topY,
|
|
w: eCx + R - (hull.cx - hull.r),
|
|
h: botY - topY,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Feed the orbs. Accepts a CombatSystem (uses its stats()) or a plain
|
|
* snapshot { hull, maxHull, shields, maxShields, energy, maxEnergy }.
|
|
* Each orb's liquid drains as its value drops (Diablo-style) — redrawn
|
|
* only when the fraction actually changes.
|
|
*/
|
|
setStats(src) {
|
|
if (!src) return;
|
|
const s = typeof src.stats === 'function' ? src.stats() : src;
|
|
const frac = (v, m) => (m > 0 ? Math.max(0, Math.min(1, v / m)) : 0);
|
|
this._setOrb(this.orbHull, frac(s.hull, s.maxHull));
|
|
this._setOrb(this.orbShield, frac(s.shields, s.maxShields));
|
|
this._setOrb(this.orbEnergy, frac(s.energy, s.maxEnergy));
|
|
}
|
|
|
|
_setOrb(orb, f) {
|
|
if (!orb) return;
|
|
if (Math.abs(f - orb.frac) < 0.0004) return;
|
|
orb.frac = f;
|
|
this._drawLiquid(orb);
|
|
}
|
|
|
|
/**
|
|
* The liquid column: a bottom-anchored fill clipped to the circle (this
|
|
* Phaser build has no circle mask — the fill is drawn as thin horizontal
|
|
* strips, each clipped to the chord width at that height), with a
|
|
* brighter surface line where the liquid meets the air.
|
|
*/
|
|
_drawLiquid(orb) {
|
|
const g = orb.liquid;
|
|
g.clear();
|
|
const f = Math.max(0, Math.min(1, orb.frac));
|
|
if (f <= 0.002) return;
|
|
const { cx, cy, r } = orb;
|
|
const top = cy + r - 2 * r * f; // the surface (f=1 → the top of the orb)
|
|
const bottom = cy + r;
|
|
const step = 2;
|
|
g.fillStyle(orb.color, orb.fillAlpha);
|
|
for (let y = top; y < bottom; y += step) {
|
|
const yMid = Math.min(bottom - 0.5, y + step * 0.5);
|
|
const dy = yMid - cy;
|
|
const hw = Math.sqrt(Math.max(0, r * r - dy * dy));
|
|
if (hw <= 0.25) continue;
|
|
g.fillRect(cx - hw, y, hw * 2, Math.min(step, bottom - y) + 0.6);
|
|
}
|
|
if (f < 0.998) {
|
|
const hw = Math.sqrt(Math.max(0, r * r - (top - cy) ** 2));
|
|
g.lineStyle(2, mixWhite(orb.color, 0.55), 0.7); // the meniscus
|
|
g.lineBetween(cx - hw, top, cx + hw, top);
|
|
}
|
|
}
|
|
|
|
/** Is (px, py) inside the bar's footprint? (GameScene's click gate.) */
|
|
contains(px, py) {
|
|
const m = 6; // a little slop, like the deck's
|
|
const r = this.rect;
|
|
return (
|
|
px >= r.x - m &&
|
|
px <= r.x + r.w + m &&
|
|
py >= r.y - m &&
|
|
py <= r.y + r.h + m
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The ORB shape (for the readability layer's hit-flash —
|
|
* js/combat/CombatHud.js blooms the orb that took the hit):
|
|
* 'hull', 'shield' or 'energy' → { type:'circle', cx, cy, r }.
|
|
*/
|
|
gaugeShape(kind) {
|
|
const o =
|
|
kind === 'hull' ? this.orbHull
|
|
: kind === 'shield' ? this.orbShield
|
|
: kind === 'energy' ? this.orbEnergy
|
|
: null;
|
|
return o ? { type: 'circle', cx: o.cx, cy: o.cy, r: o.r } : null;
|
|
}
|
|
|
|
/**
|
|
* The orb's TRACK rect (the old contract — CombatHud's rect fallback):
|
|
* the bounding square of 'hull'/'shield'/'energy'.
|
|
*/
|
|
gaugeRect(kind) {
|
|
const s = this.gaugeShape(kind);
|
|
if (!s) return null;
|
|
return { x: s.cx - s.r, y: s.cy - s.r, w: s.r * 2, h: s.r * 2 };
|
|
}
|
|
|
|
/** Tear down (scene destroy) — the children go with the scene anyway. */
|
|
destroy() {
|
|
this.onCast = null;
|
|
super.destroy();
|
|
}
|
|
}
|