545 lines
20 KiB
JavaScript
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;
|
|
}
|
|
}
|