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 { setInteractiveEnabled } from '../utils/Input.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 // The drawer belongs to the deck but RISES above it — the planets (5), // HUD (30), compass (40), toast (45) and the deck itself (50) must all // paint UNDER the menu, or the bar gets overlapped the moment it opens. this.setDepth(60); // above the command deck (50) 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; }); // CLOSED = input-inert. The v4 `ignorePointer` idiom is a no-op (it's a // physics-world property — see js/utils/Input.js), so while closed the // ghost buttons must be switched off via `input.enabled`, or they // swallow every world click in their band (and the bar starts closed). this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, false)); // ---- 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; // Input on — the buttons catch clicks (v4: input.enabled, not ignorePointer). this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, true)); 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; // Input off — the folding bar must not swallow clicks as it dies. this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, false)); // 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); setInteractiveEnabled(s.btn.panel, false); }); 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; } }