Rework combat bar into bottom-centered orb strip
- Replace vertical gauges with draining hull/shield/energy orbs (hull with overlapping shield on the left, energy on the right). - Share the main game's CombatBar in the combat range and add range energy tracking with optional regen. - Make gauge hit flashes shape-aware: circle orbs get a soft bloom, rects keep the legacy white pulse. - Update combat data and assets for the new orb-strip storyboard.
This commit is contained in:
parent
11f315dbf0
commit
c352e089e8
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -14,7 +14,7 @@
|
|||
},
|
||||
|
||||
"energy": {
|
||||
"_comment": "The player's ENERGY — the skill resource (the combat bar's right gauge). The eight skills (keys 1-8, the bar's middle seats) will spend it; for now it just sits full. regen = energy/s recovered (0 = static).",
|
||||
"_comment": "The player's ENERGY — the skill resource (the combat bar's right ORB — the main game's and the range's). The eight skills (keys 1-8, the bar's middle seats) will spend it; for now it just sits full. regen = energy/s recovered (0 = static).",
|
||||
"max": 100,
|
||||
"regen": 0
|
||||
},
|
||||
|
|
@ -153,27 +153,32 @@
|
|||
},
|
||||
|
||||
"hud": {
|
||||
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line at the bottom.",
|
||||
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line (top-left — the bottom-center is the combat bar's now).",
|
||||
"title": "COMBAT // SANDBOX",
|
||||
"hints": "WASD / arrows — fly · space — fire · R — reset range · P — pause"
|
||||
},
|
||||
|
||||
"bar": {
|
||||
"_comment": "The COMBAT BAR (js/ui/CombatBar.js) — shown in place of the action deck while combat is live (the deck hides, the bar takes its place; when the pack is wiped it's the other way around). geometry = the bar's screen rect (margin/height mirror actionbar's, so it sits in the deck's place). Left = the vertical SHIELDS + HULL gauges; right = the vertical ENERGY gauge; middle = slot.count skill seats wearing keys 1-8 (the skills land later — for now they are empty seats). Colors fall back to data/theme.json where sensible.",
|
||||
"height": 92,
|
||||
"margin": { "left": 20, "right": 20, "bottom": 12 },
|
||||
"_comment": "The COMBAT BAR (js/ui/CombatBar.js) — shown in place of the action deck while combat is live (the deck hides, the bar takes its place; when the pack is wiped it's the other way around). geometry = a BOTTOM-ALIGNED, CENTERED strip sized to its contents (assets/bar.png is the storyboard — not a color ref). Left end = the hull ORB (red) with the SHIELD orb (cyan, semi-transparent) overlapping it — shields in front of hull; middle = slot.count skill seats wearing keys 1-8 (the skills land later — for now they are empty seats); right end = the ENERGY orb (yellow, the skills' resource). The orbs fill like a Diablo orb: the liquid drains as the value drops. circle.radius = the hull/energy orb radius; circle.shield.scale = the shield orb's radius as a fraction of the hull radius — the shield orb shares the hull's CENTER (offsetX/offsetY default 0) and its liquid is semi-transparent over the hull's; orbGap = orb-to-slot spacing; margin.edge = the strip's overhang past the orbs. Colors fall back to data/theme.json where sensible.",
|
||||
"strip": { "height": 64 },
|
||||
"margin": { "bottom": 26, "edge": 10 },
|
||||
"orbGap": 24,
|
||||
"circle": {
|
||||
"radius": 46,
|
||||
"hull": { "fillAlpha": 0.95 },
|
||||
"shield": { "scale": 1.12, "offsetX": 0, "offsetY": 0, "fillAlpha": 0.55 }
|
||||
},
|
||||
"colors": {
|
||||
"bg": "#070d18",
|
||||
"bgAlpha": 0.92,
|
||||
"edge": "#22405f",
|
||||
"shield": "#00e5ff",
|
||||
"hull": "#7dff8a",
|
||||
"hull": "#ff2d6f",
|
||||
"energy": "#ffd84d",
|
||||
"slotBg": "#0b1322",
|
||||
"slotBorder": "#22405f",
|
||||
"key": "#5c7196"
|
||||
},
|
||||
"gauge": { "width": 14, "gap": 8, "pad": 12, "labelSize": 11 },
|
||||
"slot": { "count": 8, "size": 64, "gap": 10, "keySize": 12 }
|
||||
"slot": { "count": 8, "size": 44, "gap": 10, "keySize": 12 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 504 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 122 KiB |
|
|
@ -2,6 +2,7 @@ import Phaser from '../vendor/phaser.js';
|
|||
import { config } from '../config/Config.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||
import { Fx } from './Fx.js';
|
||||
|
||||
// Codebase pattern: a local fallback stack passed into fontStack()
|
||||
// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
|
||||
|
|
@ -23,9 +24,9 @@ const TAU = Math.PI * 2;
|
|||
* range's arena is one screen, so they rarely fire there),
|
||||
* - the KILL FEED — the recent kills, top-right, fading,
|
||||
* - the GAUGE FLASH — a white pulse over the combat bar's shields/hull
|
||||
* gauge when YOU take that layer's hit (the bar's rect is supplied
|
||||
* gauge when YOU take that layer's hit (the bar's SHAPE is supplied
|
||||
* by the context — the main game's js/ui/CombatBar.js, the range's
|
||||
* HUD — and the flash itself lives HERE, one shared path).
|
||||
* — and the flash itself lives HERE, one shared path).
|
||||
*
|
||||
* The RULES are pure and tested (js/combat/Combat.js → pickTarget,
|
||||
* feedPush, offscreenArrow); the numbers are data/combat.json →
|
||||
|
|
@ -43,7 +44,12 @@ export class CombatHud {
|
|||
* getCamera () => camera|null — the scrolling camera (main game);
|
||||
* null in the range (the arena is one fixed screen)
|
||||
* gaugeRect (kind) => {x,y,w,h}|null — the gauge rect to flash for
|
||||
* 'shield'/'hull' (the context's own bar)
|
||||
* 'shield'/'hull' (the context's own bar; the legacy rect-pulse contract)
|
||||
* gaugeShape (kind) => {type:'circle',cx,cy,r} | {type:'rect',x,y,w,h}
|
||||
* | null — the gauge to flash for 'shield'/'hull'. A
|
||||
* circle blooms as a soft additive dot (the orb's hit
|
||||
* read); a rect is the white pulse. Takes precedence over
|
||||
* gaugeRect when present.
|
||||
* worldToScreen (x, y) => {x, y} — world→viewport (zoom-aware). If
|
||||
* omitted, falls back to the camera's scroll (zoom 1).
|
||||
*/
|
||||
|
|
@ -54,6 +60,7 @@ export class CombatHud {
|
|||
this.getCamera = typeof o.getCamera === 'function' ? o.getCamera : null;
|
||||
this.worldToScreen = typeof o.worldToScreen === 'function' ? o.worldToScreen : null;
|
||||
this.gaugeRect = typeof o.gaugeRect === 'function' ? o.gaugeRect : null;
|
||||
this.gaugeShape = typeof o.gaugeShape === 'function' ? o.gaugeShape : null;
|
||||
|
||||
const r = config.section('combat.readability', {});
|
||||
this.reticle = r.reticle ?? {};
|
||||
|
|
@ -258,26 +265,47 @@ export class CombatHud {
|
|||
// ------------------------------------------------------------ the flash
|
||||
|
||||
/**
|
||||
* Flash a gauge (the player's hit read on the bar): a white pulse over
|
||||
* the rect the context reports for `kind` ('shield'/'hull'). The flash
|
||||
* is stepped in update() — one shared path for the main bar and the
|
||||
* range's HUD gauges.
|
||||
* Flash a gauge (the player's hit read on the bar) over the shape the
|
||||
* context reports for `kind` ('shield'/'hull'). Shape-aware (gaugeShape,
|
||||
* preferred): a CIRCLE blooms as a soft additive dot (the orb's hit
|
||||
* read — the dot's soft falloff covers the orb and spills just past
|
||||
* the rim), a RECT is the classic white pulse. Without a shape, the
|
||||
* legacy gaugeRect rect is used. The flash is stepped in update() —
|
||||
* one shared path for both contexts' bars.
|
||||
*/
|
||||
flash(kind) {
|
||||
const t = this._now();
|
||||
this.lastFlash = { kind, at: t };
|
||||
const rect = this.gaugeRect ? this.gaugeRect(kind) : null;
|
||||
if (!rect || typeof this.scene.add?.rectangle !== 'function') return null;
|
||||
const life = Math.max(60, Number(this.barFlash.life) || 340);
|
||||
const alpha = Math.max(0, Math.min(1, Number(this.barFlash.alpha) ?? 0.85));
|
||||
const o = this.scene.add.rectangle(
|
||||
rect.x + rect.w / 2,
|
||||
rect.y + rect.h / 2,
|
||||
rect.w,
|
||||
rect.h,
|
||||
0xffffff,
|
||||
0,
|
||||
);
|
||||
const shape = this.gaugeShape ? this.gaugeShape(kind) : null;
|
||||
let o = null;
|
||||
if (shape && shape.type === 'circle' && typeof this.scene.add?.image === 'function') {
|
||||
const key = Fx.ensureDot(this.scene);
|
||||
o = this.scene.add.image(shape.cx, shape.cy, key);
|
||||
o.setTint(0xffffff);
|
||||
o.setBlendMode(Phaser.BlendModes.ADD);
|
||||
o.setAlpha(0);
|
||||
// Scale so the dot's 0.26-alpha ring lands on the orb's rim.
|
||||
o.setScale(Math.max(4, Number(shape.r) || 46) / 17.6);
|
||||
} else if (typeof this.scene.add?.rectangle === 'function') {
|
||||
const rect = shape && shape.type === 'rect'
|
||||
? shape
|
||||
: this.gaugeRect
|
||||
? this.gaugeRect(kind)
|
||||
: null;
|
||||
if (rect && Number.isFinite(rect.w) && Number.isFinite(rect.h)) {
|
||||
o = this.scene.add.rectangle(
|
||||
rect.x + rect.w / 2,
|
||||
rect.y + rect.h / 2,
|
||||
rect.w,
|
||||
rect.h,
|
||||
0xffffff,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!o) return null;
|
||||
if (typeof o.setScrollFactor === 'function') o.setScrollFactor(0);
|
||||
if (typeof o.setDepth === 'function') o.setDepth(60);
|
||||
this.flashes.push({ rect: o, t0: t, life, alpha });
|
||||
|
|
@ -317,7 +345,7 @@ export class CombatHud {
|
|||
this.feed = [];
|
||||
this.feedTexts = [];
|
||||
this.flashes = [];
|
||||
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = null;
|
||||
this.getEnemies = this.getTarget = this.getCamera = this.gaugeRect = this.gaugeShape = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { StarDrift } from './StarDrift.js';
|
|||
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
|
||||
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
|
||||
import { CombatHud } from './CombatHud.js';
|
||||
import { CombatBar } from '../ui/CombatBar.js';
|
||||
|
||||
/**
|
||||
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
|
||||
|
|
@ -106,6 +107,13 @@ export class CombatScene extends Phaser.Scene {
|
|||
this.maxShields = Math.max(0, config.get('combat.playerShip.shields', 40));
|
||||
this.hull = this.maxHull;
|
||||
this.shields = this.maxShields;
|
||||
// ENERGY — the skill resource (→ energy; the bar's right orb). The
|
||||
// range's skills land with the real combat port — for now the orb
|
||||
// just sits full (regen = energy/s, 0 = static).
|
||||
const en = config.section('combat.energy', {});
|
||||
this.maxEnergy = Math.max(1, Number(en.max) || 100);
|
||||
this.energy = this.maxEnergy;
|
||||
this.energyRegen = Math.max(0, Number(en.regen) || 0);
|
||||
this.lastHitAt = -1e9;
|
||||
this.ship = new Ship(this, w * 0.24, h / 2);
|
||||
this.ship.setDepth(10);
|
||||
|
|
@ -148,7 +156,7 @@ export class CombatScene extends Phaser.Scene {
|
|||
getTarget: () =>
|
||||
this.playerDead || !this.ship ? null : pickTarget(this.enemies, this.ship.x, this.ship.y),
|
||||
// No scrolling camera — the arena is a fixed full screen.
|
||||
gaugeRect: (kind) => this.gaugeRects?.[kind] ?? null,
|
||||
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
|
||||
});
|
||||
|
||||
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause.
|
||||
|
|
@ -314,6 +322,10 @@ export class CombatScene extends Phaser.Scene {
|
|||
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
|
||||
}
|
||||
}
|
||||
// Energy regen (energy.* — the bar's right orb; static when regen 0).
|
||||
if (this.energy < this.maxEnergy && this.energyRegen > 0) {
|
||||
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
|
||||
}
|
||||
}
|
||||
|
||||
firePlayer(heading, wpn) {
|
||||
|
|
@ -645,6 +657,7 @@ export class CombatScene extends Phaser.Scene {
|
|||
if (!config.get('combat.sandbox.respawn', true) || time < this.respawnAt) return;
|
||||
this.hull = this.maxHull;
|
||||
this.shields = this.maxShields;
|
||||
this.energy = this.maxEnergy;
|
||||
this.ship.x = this.w * 0.24;
|
||||
this.ship.y = this.h / 2;
|
||||
this.ship.rotation = 0;
|
||||
|
|
@ -697,7 +710,6 @@ export class CombatScene extends Phaser.Scene {
|
|||
const hud = config.section('combat.hud', {});
|
||||
const headerFont = fontStack('header', FONT_FALLBACK);
|
||||
const bodyFont = fontStack('body', FONT_FALLBACK);
|
||||
const ink = themeColor('ink', 0xeaf6ff);
|
||||
const dim = themeColor('dim', 0x8aa0c0);
|
||||
const faint = themeColor('faint', 0x55688a);
|
||||
const neon = themeColor('neon', 0x00e5ff);
|
||||
|
|
@ -719,54 +731,22 @@ export class CombatScene extends Phaser.Scene {
|
|||
})
|
||||
.setOrigin(1, 0).setDepth(D);
|
||||
|
||||
// The armor panel (top-left): HULL bar + SHD bar + live numbers.
|
||||
const pw = 330;
|
||||
const ph = 96;
|
||||
const px = 28;
|
||||
const py = 28;
|
||||
// The combat bar (bottom-center orb strip — js/ui/CombatBar.js, the
|
||||
// SAME component the main game swaps in for the action deck): the
|
||||
// hull + shield orbs, the eight skill seats (keys 1-8, the skills
|
||||
// land with the real combat port — for now empty seats), and the
|
||||
// energy orb. Its orbs are the readability layer's flash targets
|
||||
// (the same shared flash the main game's bar gets).
|
||||
this.combatBar = new CombatBar(this, {
|
||||
onCast: () => {}, // no skills on the range yet — the seats are visual
|
||||
});
|
||||
|
||||
// The control line (top-left — the bottom-center is the bar's now).
|
||||
this.add
|
||||
.graphics()
|
||||
.setDepth(D)
|
||||
.fillStyle(0x0a1120, 0.72)
|
||||
.fillRoundedRect(px, py, pw, ph, 8)
|
||||
.lineStyle(1, toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x22405f), 0.9)
|
||||
.strokeRoundedRect(px, py, pw, ph, 8);
|
||||
|
||||
const lx = px + 16; // label x
|
||||
const bx = px + 16; // bar x (left edge)
|
||||
const bw = 210; // bar width
|
||||
const numX = bx + bw + 14;
|
||||
|
||||
this.add.text(lx, py + 22, 'HULL', {
|
||||
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
|
||||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
this.hullBarBg = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, 0x101a2c).setDepth(D);
|
||||
this.hullBar = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, amber).setDepth(D);
|
||||
this.hullText = this.add.text(numX, py + 40, '100', {
|
||||
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
|
||||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
|
||||
this.add.text(lx, py + 62, 'SHD', {
|
||||
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
|
||||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
this.shdBarBg = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, 0x101a2c).setDepth(D);
|
||||
this.shdBar = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, neon).setDepth(D);
|
||||
// The gauge rects (for the readability layer's hit-flash — the same
|
||||
// shared flash the main game's combat bar gets).
|
||||
this.gaugeRects = {
|
||||
hull: { x: bx, y: py + 35.5, w: bw, h: 9 },
|
||||
shield: { x: bx, y: py + 75.5, w: bw, h: 9 },
|
||||
};
|
||||
this.shdText = this.add.text(numX, py + 80, '40', {
|
||||
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
|
||||
}).setOrigin(0, 0.5).setDepth(D);
|
||||
|
||||
// The control line (bottom-center).
|
||||
this.add
|
||||
.text(w / 2, h - 12, hud.hints ?? '', {
|
||||
.text(28, 22, hud.hints ?? '', {
|
||||
fontFamily: bodyFont, fontSize: '12px', color: toCss(faint, '#55688a'), letterSpacing: 1,
|
||||
})
|
||||
.setOrigin(0.5, 1).setDepth(D);
|
||||
.setOrigin(0, 0.5).setDepth(D);
|
||||
|
||||
// Center message (SIGNAL LOST / PAUSED).
|
||||
this.msgText = this.add
|
||||
|
|
@ -781,25 +761,17 @@ export class CombatScene extends Phaser.Scene {
|
|||
.setOrigin(0.5).setAlpha(0).setDepth(D);
|
||||
}
|
||||
|
||||
setBar(bar, frac) {
|
||||
const w = Math.max(0, Math.min(1, frac)) * 210;
|
||||
bar.width = w;
|
||||
bar.x = 28 + 16 + w / 2; // left-anchored (rectangles are center-origin)
|
||||
}
|
||||
|
||||
updateHud(time) {
|
||||
const hullShown = Math.ceil(this.hull);
|
||||
const shdShown = Math.ceil(this.shields);
|
||||
if (this.lastHullShown !== hullShown) {
|
||||
this.lastHullShown = hullShown;
|
||||
this.hullText.setText(String(hullShown));
|
||||
}
|
||||
if (this.lastShdShown !== shdShown) {
|
||||
this.lastShdShown = shdShown;
|
||||
this.shdText.setText(String(shdShown));
|
||||
}
|
||||
this.setBar(this.hullBar, this.hull / this.maxHull);
|
||||
this.setBar(this.shdBar, this.maxShields > 0 ? this.shields / this.maxShields : 0);
|
||||
// The combat bar's orbs (the liquid drains as the values drop):
|
||||
// hull + shield left, energy right.
|
||||
this.combatBar?.setStats({
|
||||
hull: this.hull,
|
||||
maxHull: this.maxHull,
|
||||
shields: this.shields,
|
||||
maxShields: this.maxShields,
|
||||
energy: this.energy,
|
||||
maxEnergy: this.maxEnergy,
|
||||
});
|
||||
|
||||
const killsShown = `KILLS ${this.kills}`;
|
||||
if (this.lastKillsShown !== killsShown) {
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ export class GameScene extends Phaser.Scene {
|
|||
const z = c?.zoom ?? 1;
|
||||
return { x: (x - ox) * z, y: (y - oy) * z };
|
||||
},
|
||||
gaugeRect: (kind) => this.combatBar?.gaugeRect(kind) ?? null,
|
||||
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
// Tuning handle: poke the pack / the armor / the bar from the console.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,69 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor, toCss } from '../utils/Color.js';
|
||||
import { fontStack, themeColor } from '../utils/Theme.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 COMBAT BAR — the main game's combat-mode action 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 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):
|
||||
*
|
||||
* [ SHD | HUL ] [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ] [ ENR ]
|
||||
* (HULL orb + SHIELD orb) [1][2][3][4][5][6][7][8] (ENERGY orb)
|
||||
*
|
||||
* left — the player's vertical SHIELDS + HULL gauges
|
||||
* 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 player's vertical ENERGY gauge (the skills' resource)
|
||||
* 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.
|
||||
* 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 {
|
||||
/**
|
||||
|
|
@ -36,151 +77,266 @@ export class CombatBar extends Phaser.GameObjects.Container {
|
|||
// 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(50); // the deck's depth
|
||||
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 mL = Number(bar.margin?.left) || 20;
|
||||
const mR = Number(bar.margin?.right) || 20;
|
||||
const mB = Number(bar.margin?.bottom) || 12;
|
||||
const barH = Number(bar.height) || 92;
|
||||
|
||||
const x0 = mL;
|
||||
const y0 = H - mB - barH;
|
||||
this.rect = { x: x0, y: y0, w: W - mL - mR, h: barH };
|
||||
|
||||
const c = bar.colors ?? {};
|
||||
const panel = toColor(c.bg, themeColor('panel', 0x0b1322));
|
||||
const edge = toColor(c.edge, themeColor('neon', 0x00e5ff));
|
||||
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 hullC = toColor(c.hull, 0x7dff8a);
|
||||
const energyC = toColor(c.energy, 0xffd84d);
|
||||
const slotBg = toColor(c.slotBg, 0x0b1322);
|
||||
const slotEdge = toColor(c.slotBorder, 0x22405f);
|
||||
const keyC = toColor(c.key, 0x5c7196);
|
||||
const alpha = Number(bar.bgAlpha) || 0.92;
|
||||
const slotBorder = toColor(c.slotBorder, 0x22405f);
|
||||
const keyCss = toCss(c.key, '#5c7196');
|
||||
|
||||
// ---- the panel (the deck's footprint — margin/height mirror it) ----
|
||||
this.add(this._rect(x0, y0, this.rect.w, barH, panel, alpha));
|
||||
this.add(this._rect(x0, y0, this.rect.w, 2, edge, 0.4)); // top edge
|
||||
// ---- 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 gauges (vertical; fills grow UP from the bottom) ----------
|
||||
const g = bar.gauge ?? {};
|
||||
const gaugeW = Number(g.width) || 14;
|
||||
const gaugeGap = Number(g.gap) || 8;
|
||||
const pad = Number(g.pad) || 12;
|
||||
const labelPx = Number(g.labelSize) || 11;
|
||||
const gaugeH = barH - pad * 2 - labelPx - 4;
|
||||
const gaugeTop = y0 + pad;
|
||||
// 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 mkGauge = (cx, color, label) => {
|
||||
this.add(this._rect(cx - gaugeW / 2, gaugeTop, gaugeW, gaugeH, slotBg, 1));
|
||||
// The fill: a bottom-anchored rect whose scaleY IS the level.
|
||||
const fill = scene.add.rectangle(cx, gaugeTop + gaugeH, gaugeW - 4, gaugeH - 4, color, 1);
|
||||
fill.setOrigin(0.5, 1);
|
||||
fill.setScrollFactor(0);
|
||||
this.add(fill);
|
||||
const labelT = scene.add
|
||||
.text(cx, y0 + barH - 11, label, {
|
||||
fontFamily: fontStack('body', FONT_FALLBACK),
|
||||
fontSize: `${labelPx}px`,
|
||||
color: toCss(themeColor('faint', 0x8391ad)),
|
||||
letterSpacing: 1,
|
||||
})
|
||||
.setOrigin(0.5, 0.5)
|
||||
.setScrollFactor(0);
|
||||
this.add(labelT);
|
||||
return { fill, frac: 1, track: { x: cx - gaugeW / 2, y: gaugeTop, w: gaugeW, h: gaugeH } };
|
||||
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;
|
||||
};
|
||||
|
||||
const leftX = x0 + pad + gaugeW / 2;
|
||||
this.gShield = mkGauge(leftX, shieldC, 'SHD');
|
||||
this.gHull = mkGauge(leftX + gaugeW + gaugeGap, hullC, 'HUL');
|
||||
const rightX = x0 + this.rect.w - pad - gaugeW / 2;
|
||||
this.gEnergy = mkGauge(rightX, energyC, 'ENR');
|
||||
// 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);
|
||||
|
||||
// ---- the skill seats (middle; keys 1-8; the skills land later) -----
|
||||
const s = bar.slot ?? {};
|
||||
const count = Math.max(0, Number(s.count) || 8);
|
||||
const size = Number(s.size) || 64;
|
||||
const gap = Number(s.gap) || 10;
|
||||
const keyPx = Number(s.keySize) || 12;
|
||||
const totalW = count * size + (count - 1) * gap;
|
||||
let sx = W / 2 - totalW / 2 + size / 2;
|
||||
const sy = y0 + barH / 2;
|
||||
this.slots = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const bg = scene.add.rectangle(sx - size / 2, sy - size / 2, size, size, slotBg, 1);
|
||||
bg.setScrollFactor(0);
|
||||
this.add(bg);
|
||||
this.add(this._rect(sx - size / 2, sy - size / 2, size, 1, slotEdge, 0.9)); // top lip
|
||||
bg.setInteractive({ useHandCursor: true });
|
||||
bg.on('pointerdown', () => {
|
||||
if (this.onCast) this.onCast(i);
|
||||
});
|
||||
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(sx - size / 2 + 6, sy - size / 2 + 5, String(i + 1), {
|
||||
.text(lx + 6, ly + 5, String(i + 1), {
|
||||
fontFamily: fontStack('body', FONT_FALLBACK),
|
||||
fontSize: `${keyPx}px`,
|
||||
color: toCss(keyC),
|
||||
color: keyCss,
|
||||
})
|
||||
.setOrigin(0, 0.5)
|
||||
.setScrollFactor(0);
|
||||
.setScrollFactor(0)
|
||||
.setDepth(DEPTH);
|
||||
this.add(keyT);
|
||||
this.slots.push({ x: sx, bg, keyT });
|
||||
sx += size + gap;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A solid rectangle, created on the scene (the scene.add.* factory is
|
||||
* what puts it on the display list) — the bar's panel/chrome primitive.
|
||||
* The caller parents it into the container with this.add(rect).
|
||||
*/
|
||||
_rect(x, y, w, h, color, alpha = 1) {
|
||||
const r = this.scene.add.rectangle(x, y, w, h, color, alpha);
|
||||
r.setScrollFactor(0);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the gauges. Accepts a CombatSystem (uses its stats()) or a
|
||||
* plain snapshot { hull, maxHull, shields, maxShields, energy, maxEnergy }.
|
||||
* 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._gauge(this.gShield, frac(s.shields, s.maxShields));
|
||||
this._gauge(this.gHull, frac(s.hull, s.maxHull));
|
||||
this._gauge(this.gEnergy, frac(s.energy, s.maxEnergy));
|
||||
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));
|
||||
}
|
||||
|
||||
_gauge(g, f) {
|
||||
g.frac = f;
|
||||
g.fill.setScale(1, Math.max(0.001, f));
|
||||
_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 >= this.rect.x - m &&
|
||||
px <= this.rect.x + this.rect.w + m &&
|
||||
py >= this.rect.y - m &&
|
||||
py <= this.rect.y + this.rect.h + m
|
||||
px >= r.x - m &&
|
||||
px <= r.x + r.w + m &&
|
||||
py >= r.y - m &&
|
||||
py <= r.y + r.h + m
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gauge's TRACK rect (for the readability layer's hit-flash —
|
||||
* js/combat/CombatHud.js flashes this rect when the player takes that
|
||||
* layer's hit): 'shield' or 'hull'.
|
||||
* 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 g = kind === 'hull' ? this.gHull : this.gShield;
|
||||
return g && g.track ? g.track : null;
|
||||
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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue