orbit/js/ui/MenuSubBar.js

545 lines
20 KiB
JavaScript

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { MenuButton } from './MenuButton.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
/**
* The MENU sub-bar — the drawer that folds up out of the command deck's
* Menu button when it's pressed (js/ui/ActionBar.js, last slot).
*
* Three buttons (data/save.json → subBar.items):
* SAVE GAME · LOAD GAME (grayed while no saves exist) · RETURN TO MAIN MENU
*
* THE OPEN — a signal unfolding out of the seam (bottom-up, ~340 ms):
* t=0 the deck fires a glitch burst (slice bars + its panel's RGB
* pull-apart — the scene triggers it), the camera kicks, and a
* hot seam line flares along the button's top edge;
* 0→H a bright UNFOLD EDGE climbs the panel — the body is redrawn
* taller every frame behind it (the deck grows out of the
* button), a cyan→magenta rail rides the edge, the scanlines
* fill in under it;
* mid as the edge crosses the button band they flicker up one by
* one (Steps, left→right) and their labels DECODE in — the
* console pulling words out of static;
* lock the panel's RGB channel ghosts (magenta/cyan outlines,
* ±12 px apart) converge to zero as the bar settles — the
* channels locking onto the signal; slice bars tear once and
* die; the seam line settles into a faint breathing power rail.
*
* THE CLOSE is the same language in reverse at ⅓ the length: the edge
* folds back down, the labels deconstruct, the channels drift apart and
* cut.
*
* While open it keeps a low-level life: a breathing seam rail, an
* occasional faint micro-burst (ghost pulse + a slice or two).
*
* Screen-space: the container AND every child are scrollFactor 0 (the
* v4 per-child input quirk — ActionBar's note). The scene calls
* update(time, delta) and owns the trigger (its onAction('menu')).
*
* const bar = new MenuSubBar(scene, { anchor: {x, y, w}, onAction });
* bar.open(); bar.close(); bar.setDisabled('load', true);
* bar.contains(px, py); bar.update(time, delta); bar.destroy();
*/
export class MenuSubBar extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
* @param {object} [o] {
* anchor: { x, y, w } — the menu button's center (x, y) + width (screen px)
* onAction?: (id) => void
* }
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed UI
const cfg = config.section('save.subBar', {});
const anim = cfg.animation ?? {};
const H = cfg.height ?? 58;
this.H = H;
this.pad = cfg.padding ?? 16;
this.gap = cfg.gap ?? 12;
this.onAction = typeof o.onAction === 'function' ? o.onAction : null;
const c = config.section('save.colors', {});
this.panelTop = c.panelTop ?? '#0e1930';
this.panelBottom = c.panelBottom ?? '#050912';
this.neon = toColor(c.neon ?? '#00e5ff');
this.magenta = toColor(c.magenta ?? '#ff2d6f');
this.ink = toColor(c.ink ?? '#eaf6ff');
this.dim = toColor(c.dim ?? '#7d92c4');
// ---- the body graphics FIRST: v4 paints children in list order, so
// the opaque panel fill must come before the buttons or it would
// cover them (the body is redrawn every frame while it unfolds).
// The button row width is measured up-front (throw-away texts) so the
// scan tile can be sized correctly from the start.
const items = Array.isArray(cfg.items) && cfg.items.length
? cfg.items
: [
{ id: 'save', label: 'Save Game', accent: '#00e5ff' },
{ id: 'load', label: 'Load Game', accent: '#ffc94d' },
{ id: 'mainMenu', label: 'Return to Main Menu', accent: '#ff2d6f' },
];
const btnCfg = cfg.button ?? {};
const sceneW = scene.scale.width;
// ---- create the buttons first (they measure their own label) so the
// row layout uses the REAL button widths — no second, divergent
// text-measurement pass.
const mkBtn = (item) => {
const btn = new MenuButton(
scene,
0,
-H / 2,
String(item.label ?? item.id),
() => this.fire(item.id),
{
screenFixed: true,
fontSize: btnCfg.fontSize ?? 13,
paddingX: btnCfg.paddingX ?? 18,
paddingY: btnCfg.paddingY ?? 9,
letterSpacing: btnCfg.letterSpacing ?? 2,
upper: true,
textColor: c.ink ?? '#eaf6ff',
bgColor: c.cardBg ?? '#0a1222',
hoverColor: c.cardBg ?? '#0a1222',
borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'),
},
);
// The accent edge: the button's stroke + hover glow take the item's accent.
btn.style.stroke = toColor(item.accent ?? this.neon);
btn.style.neon = toColor(item.accent ?? this.neon);
btn.setAlpha(0);
return btn;
};
const btns = items.map((it) => mkBtn(it));
const widths = btns.map((b) => b.style.width);
let W = this.pad * 2 + widths.reduce((a, b) => a + b, 0) + this.gap * (widths.length - 1);
W = Math.min(W, sceneW - 24);
this.W = W;
// ---- the body (redrawn each frame while it unfolds) ---------------
this.bodyG = scene.add.graphics().setScrollFactor(0);
this.add(this.bodyG);
// The unfold edge (bright line + glow trail) — the moving top of the
// revealed body. Additive.
this.edgeG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
this.add(this.edgeG);
// RGB channel ghosts — full-size outlines that converge on open,
// diverge on close (the GlitchText technique, applied to the panel).
this.ghostC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
this.ghostM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
this.add([this.ghostC, this.ghostM]);
// Scanlines over the revealed body (the deck's tile, sized to it).
const sl = cfg.scanline ?? {};
const pitch = Math.max(2, Math.round(sl.pitch ?? 3));
const scanKey = `__subbar_scan_${pitch}`;
canvasTexture(scene, scanKey, 1, pitch, (ctx) => {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, pitch - 1, 1, 1);
});
this.scan = scene.add.tileSprite(0, -H / 2, this.W, 0, scanKey).setScrollFactor(0).setAlpha(0);
this.add(this.scan);
// The seam line — the power connection button↔bar (additive, animated).
this.seamG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
this.add(this.seamG);
// ---- add the buttons LAST so they paint above the body; center the
// row about the origin (-W/2..W/2) — left-origin coords (0..W) would
// push the last button W/2 past the bar's right edge.
let bx = -this.W / 2 + this.pad + widths[0] / 2;
this.buttons = items.map((it, i) => {
const btn = btns[i];
btn.setX(bx);
this.add(btn);
const slot = { id: it.id, item: it, btn, finalLabel: String(it.label ?? it.id).toUpperCase(), _dec: null, up: false };
// center → center: this button's half + gap + next button's half.
bx += widths[i] / 2 + this.gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
return slot;
});
// ---- the anchor: right-aligned over the menu button, seam on its top edge
const a = o.anchor ?? { x: sceneW / 2, y: scene.scale.height - 60, w: 120 };
let cx = a.x + a.w / 2 - this.W / 2; // bar's right edge on the button's right edge
cx = Phaser.Math.Clamp(cx, this.W / 2 + 12, sceneW - this.W / 2 - 12);
const seamY = a.y - 0; // the bar's bottom edge sits ON the button (seam = button top)
this.seamY = seamY;
this.setPosition(cx, seamY);
this.rect = { x: cx - this.W / 2, y: seamY - H, w: this.W, h: H };
this.slices = []; // { g, die }
this.state = 'closed'; // closed | opening | open | closing
this.t0 = 0;
this.lastTime = null;
this.openDur = anim.openMs ?? 340;
this.closeDur = anim.closeMs ?? 200;
this.ghostStart = anim.ghostStart ?? 12;
this.buttonStart = anim.buttonStart ?? 0.34;
this.staggerFrac = (anim.staggerMs ?? 70) / Math.max(1, this.openDur);
this.micro = { enabled: (anim.microBurst?.enabled !== false), every: anim.microBurst?.intervalMs ?? [2800, 6800] };
this.nextMicroAt = null;
this.level = 0; // ambient micro-burst level (drives ghost pulses)
this.drawGhosts();
}
// ------------------------------------------------------------------
// Public
// ------------------------------------------------------------------
get isOpen() {
return this.state === 'open' || this.state === 'opening';
}
/** True while the close animation is running (the bar is already dead
* as far as input goes — GameScene uses this so a click that dismissed
* it can't re-trigger its seams regardless of event order). */
get closing() {
return this.state === 'closing';
}
open() {
if (this.state !== 'closed' || this.dead) return;
this.state = 'opening';
this.t0 = this.lastTime ?? this.scene.time.now;
this.buttons.forEach((s) => {
s.up = false;
s._dec = null;
s.btn.setAlpha(0);
s.btn.labelText.setText('');
// NOTE: the disabled (grayed) state is the SCENE's (menuAction sets it
// right before open) — never reset it here, or Load Game un-grays.
});
// The seam flares button-width → bar-width as the signal leaves the button.
this.seamFlash = { t0: this.t0, dur: 260, from: this.rect.w * 0.001, to: this.W };
this.spawnSlices(1);
this.scene.playSfx?.('construct');
}
close() {
if (this.state !== 'open' || this.dead) return;
this.state = 'closing';
this.t0 = this.lastTime ?? this.scene.time.now;
// Labels deconstruct (the reverse decode) while the edge folds down.
this.buttons.forEach((s, i) => {
s._dec = new ScrambleDecode(s.finalLabel, this.t0, 150, true);
if (i > 0) s._dec.t0 = this.t0 + i * 25;
});
this.spawnSlices(0.8);
this.scene.playSfx?.('deconstruct');
}
/** Instantly kill the bar (scene transitions). */
dismiss() {
this.state = 'closed';
this.bodyG.clear();
this.edgeG.clear();
this.ghostC.setAlpha(0);
this.ghostM.setAlpha(0);
this.seamG.clear();
this.scan.setAlpha(0).setSize(this.W, 0);
this.buttons.forEach((s) => s.btn.setAlpha(0));
for (const sl of this.slices) sl.g.destroy();
this.slices.length = 0;
}
/** Gray a sub-bar button out (Load Game while no saves exist). */
setDisabled(id, on) {
const s = this.buttons.find((b) => b.id === id);
if (s) s.btn.setDisabled(!!on);
}
/** Is (px, py) — screen coords — over the bar? */
contains(px, py) {
const r = this.rect;
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
fire(id) {
if (typeof this.onAction === 'function') {
try {
this.onAction(id);
} catch (err) {
console.error('[subbar] onAction failed', err);
}
}
}
// ------------------------------------------------------------------
// Drawing
// ------------------------------------------------------------------
/**
* The panel body at revealed height h (0..H), bottom edge on the seam
* (local y=0), top corners cut. The top edge is the UNFOLD EDGE — a
* bright cyan→magenta rail; at h=H it's the bar's final top rail.
*/
drawBody(h) {
const g = this.bodyG;
g.clear();
const W = this.W;
h = Math.max(0, Math.min(this.H, h));
if (h < 1) return;
const cut = Math.max(3, Math.min(12, h * 0.18));
const pts = [
{ x: -W / 2 + cut, y: -h },
{ x: W / 2 - cut, y: -h },
{ x: W / 2, y: -h + cut },
{ x: W / 2, y: 0 },
{ x: -W / 2, y: 0 },
{ x: -W / 2, y: -h + cut },
];
// Body fill: deep console blue, brighter toward the (moving) top edge.
g.fillStyle(toColor(this.panelBottom), 1);
g.fillPoints(pts, true);
g.fillStyle(toColor(this.panelTop), Math.min(1, h / this.H) * 0.55);
g.fillPoints(pts, true);
// Side glows (cyan left, magenta right — the deck's language).
g.fillStyle(this.neon, 0.05);
g.fillRect(-W / 2, -h, 26, h);
g.fillStyle(this.magenta, 0.06);
g.fillRect(W / 2 - 26, -h, 26, h);
// The unfold edge — the signature line, riding the revealed top.
const rail = 2;
g.fillStyle(this.neon, 0.9);
g.fillRect(-W / 2 + cut, -h, W - cut * 2, rail);
g.fillStyle(this.ink, 0.8);
g.fillRect(-W / 2 + cut, -h + 0.5, W - cut * 2, 0.8);
// A magenta counter-edge 1px below (RGB separation on the seam of the bar).
g.fillStyle(this.magenta, 0.5);
g.fillRect(-W / 2 + cut, -h + rail, W - cut * 2, 1);
// The seam (bottom) — a faint magenta floor line, always.
g.fillStyle(this.magenta, 0.35);
g.fillRect(-W / 2, -1.5, W, 1.5);
// Viewfinder brackets on the two top corners (HUD chrome).
if (h > this.H * 0.5) {
g.lineStyle(2, this.neon, 0.4);
const L = 10;
g.lineBetween(-W / 2 + 1, -h + L, -W / 2 + 1, -h + 1);
g.lineBetween(-W / 2 + 1, -h + 1, -W / 2 + L, -h + 1);
g.lineBetween(W / 2 - 1, -h + L, W / 2 - 1, -h + 1);
g.lineBetween(W / 2 - 1, -h + 1, W / 2 - L, -h + 1);
}
}
/** The bright unfold edge (line + glow trail) at revealed height h. */
drawEdge(h, alpha) {
const g = this.edgeG;
g.clear();
if (alpha <= 0.01 || h < 2) return;
const W = this.W;
g.fillStyle(this.ink, 0.85 * alpha);
g.fillRect(-W / 2, -h, W, 2);
g.fillStyle(this.neon, 0.3 * alpha);
g.fillRect(-W / 2, -h - 5, W, 5);
g.fillStyle(this.neon, 0.12 * alpha);
g.fillRect(-W / 2, -h - 12, W, 7);
}
/** The two channel ghosts (full-size outlines, additive). */
drawGhosts() {
const cut = Math.min(12, this.H * 0.18);
const pts = [
{ x: -this.W / 2 + cut, y: -this.H },
{ x: this.W / 2 - cut, y: -this.H },
{ x: this.W / 2, y: -this.H + cut },
{ x: this.W / 2, y: 0 },
{ x: -this.W / 2, y: 0 },
{ x: -this.W / 2, y: -this.H + cut },
];
const stroke = (gg, color) => {
gg.clear();
gg.lineStyle(1.5, color, 1);
gg.strokePoints(pts, true);
};
stroke(this.ghostC, this.neon);
stroke(this.ghostM, this.magenta);
}
/** The seam power line (on the button's top edge), width animated. */
drawSeam(width, alpha) {
const g = this.seamG;
g.clear();
if (alpha <= 0.01) return;
g.fillStyle(this.neon, alpha);
g.fillRect(-width / 2, -1, width, 2);
g.fillStyle(this.ink, alpha * 0.8);
g.fillRect(-width / 2, -0.5, width, 1);
}
/** Slice bars across the bar (the signal tearing), intensity 0..1. */
spawnSlices(intensity = 1) {
const g = this.scene.add.graphics().setScrollFactor(0);
g.setBlendMode(Phaser.BlendModes.ADD);
const n = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < n; i++) {
const yy = -Math.random() * this.H;
const bh = 1 + Math.random() * 7;
const dx = (Math.random() * 2 - 1) * 10;
const palette = [this.neon, this.magenta, 0xeaf6ff, 0x04060d];
g.fillStyle(palette[(Math.random() * palette.length) | 0], (0.06 + Math.random() * 0.14) * intensity);
g.fillRect(-this.W / 2 - 10 + dx, yy, this.W + 20, bh);
}
const die = (this.lastTime ?? this.scene.time.now) + 240;
this.slices.push({ g, die });
}
// ------------------------------------------------------------------
// Per-frame (scene.update drives it)
// ------------------------------------------------------------------
update(time, delta) {
if (this.dead) return;
this.lastTime = time;
const t = time * 0.001;
// Reap slices.
if (this.slices.length) {
this.slices = this.slices.filter((s) => {
if (time >= s.die) {
s.g.destroy();
return false;
}
return true;
});
}
// Micro-burst scheduling (open only) + level decay.
this.level *= Math.exp(-(delta / 140));
if (this.state === 'open') {
if (this.micro.enabled) {
if (this.nextMicroAt === null) this.nextMicroAt = time + this.range(this.micro.every[0], this.micro.every[1]);
if (time >= this.nextMicroAt) {
this.level = Math.max(this.level, 0.5);
this.nextMicroAt = time + this.range(this.micro.every[0], this.micro.every[1]);
this.spawnSlices(0.45);
}
}
}
if (this.state === 'opening') {
const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1);
const e = 1 - Math.pow(1 - p, 3); // ease-out cubic
const h = Math.max(2, e * this.H);
this.drawBody(h);
this.drawEdge(h, 0.9);
this.scan.setSize(this.W, h).setPosition(0, -h / 2).setAlpha(0.14);
// The channels converge as the bar locks in.
const off = this.ghostStart * (1 - e);
this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.3);
this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.4);
// The seam line grows button-width → bar-width, then fades to a rail.
if (this.seamFlash) {
const sp = Phaser.Math.Clamp((time - this.seamFlash.t0) / this.seamFlash.dur, 0, 1);
const w = Phaser.Math.Linear(this.seamFlash.from, this.seamFlash.to, 1 - Math.pow(1 - sp, 2));
this.drawSeam(w, 0.7 * (1 - sp * 0.4));
}
// Buttons flicker up as the edge crosses their band.
this.buttons.forEach((s, i) => {
if (s.up) return;
const ap = this.buttonStart + i * this.staggerFrac;
if (p >= ap) {
s.up = true;
this.scene.tweens.add({ targets: s.btn, alpha: 1, duration: 140, ease: 'Sine.easeOut' });
s._dec = new ScrambleDecode(s.finalLabel, time + 70, decodeDur(s.finalLabel.length));
}
});
this.driveDecodes(time);
if (p >= 1) {
this.state = 'open';
this.drawBody(this.H);
this.edgeG.clear();
this.nextMicroAt = null;
// The top rail energizes — a quick settle flash.
this.scene.tweens.add({
targets: this.bodyG,
alpha: 1,
duration: 120,
ease: 'Sine.easeOut',
});
this.bodyG.setAlpha(0.6);
}
} else if (this.state === 'open') {
// The seam power rail breathes (button↔bar connection).
this.drawSeam(this.W * 0.5, 0.1 + 0.06 * (0.5 + 0.5 * Math.sin(t * 2.1)));
// Ambient micro-burst: the channel ghosts pulse faintly.
if (this.level > 0.04) {
const jx = this.level * 5 * Math.sin(time * 0.06);
this.ghostC.setAlpha(this.level * 0.2).setPosition(jx, -this.level * 1.5);
this.ghostM.setAlpha(this.level * 0.2).setPosition(-jx, this.level);
} else {
this.ghostC.setAlpha(0);
this.ghostM.setAlpha(0);
}
this.driveDecodes(time);
} else if (this.state === 'closing') {
const p = Phaser.Math.Clamp((time - this.t0) / this.closeDur, 0, 1);
const e = p * p * (3 - 2 * p);
const h = Math.max(0, (1 - e) * this.H);
this.drawBody(h);
this.drawEdge(h, 0.8);
this.scan.setSize(this.W, h).setPosition(0, -h / 2).setAlpha(0.14 * (1 - e));
// The channels drift apart and cut — the signal dropping.
const off = this.ghostStart * e;
this.ghostC.setAlpha(0.4 * e).setPosition(off * 0.85, -off * 0.3);
this.ghostM.setAlpha(0.4 * e).setPosition(-off, off * 0.4);
this.drawSeam(this.W * 0.4 * (1 - e), 0.5 * (1 - e));
this.buttons.forEach((s) => {
s.btn.setAlpha(1 - e);
});
this.driveDecodes(time);
if (p >= 1) {
this.state = 'closed';
this.bodyG.clear();
this.edgeG.clear();
this.ghostC.setAlpha(0);
this.ghostM.setAlpha(0);
this.seamG.clear();
this.scan.setAlpha(0).setSize(this.W, 0);
this.buttons.forEach((s) => {
s.btn.setAlpha(0);
s.up = false;
s._dec = null;
});
}
}
}
/** Drive any label decodes (open-in or close-out). */
driveDecodes(time) {
for (const s of this.buttons) {
const d = s._dec;
if (!d) continue;
if (d.finished(time)) {
s.btn.labelText.setText(d.reverse ? '' : d.value);
s._dec = null;
continue;
}
if (d.started(time)) {
s.btn.labelText.setText(d.display(time));
}
}
}
range(lo, hi) {
return lo + Math.random() * (hi - lo);
}
get dead() {
return this.scene === null || this.active === false;
}
}