orbit/js/ui/ConfirmOverlay.js

338 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
import { ScrambleDecode } from '../utils/Decode.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The CONFIRM dialog — the second beat before anything destructive in
* the save pop-up: overwriting a slot, or loading one (which replaces
* the live game).
*
* Split-open: the panel splits from a 2px hot line down the middle
* (top and bottom edges fly apart), the RGB channel ghosts converge as
* the panel locks in, the title decodes, and the buttons flicker up —
* the same console language as the sub-bar and the pop-up, one step
* louder. Cancel = the line swallows the panel back down.
*
* While shown it blocks the modal behind it (SavePanel checks
* `contains`), and ESC / the CANCEL button both close it.
*
* const cf = new ConfirmOverlay(scene, cx, cy, 430, 168, { areaW, areaH });
* cf.show({ title, body: […], accent, onConfirm, onCancel, time });
* cf.cancel(); cf.escape(); cf.contains(px, py); cf.update(time);
*/
export class ConfirmOverlay extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
* @param {object} [o] { areaW?, areaH? — the modal area the scrim
* darkens (defaults to this dialog's own size + margin) }
*/
constructor(scene, x, y, w, h, o = {}) {
super(scene, x, y);
this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed
this.setSize(w, h);
const c = config.section('save.colors', {});
this.w = w;
this.h = h;
this.areaW = o.areaW ?? w + 40;
this.areaH = o.areaH ?? h + 40;
this.state = 'hidden'; // hidden | opening | shown | closing
this.t0 = 0;
this.onConfirm = null;
this.onCancel = null;
this.accent = toColor(c.neon ?? '#00e5ff');
// Scrim over the whole modal (below the panel) — the world behind
// goes quiet. Clicking outside the dialog = cancel (standard).
this.scrim = scene.add.rectangle(0, 0, this.areaW, this.areaH, 0x02040a, 0).setScrollFactor(0);
this.scrim.setInteractive({
useHandCursor: false,
hitArea: new Phaser.Geom.Rectangle(-this.areaW / 2, -this.areaH / 2, this.areaW, this.areaH),
hitAreaCallback: () => true,
});
this.scrim.on('pointerdown', () => this.cancel());
this.scrim.ignorePointer = true; // armed while shown only
this.add(this.scrim);
this.panelG = scene.add.graphics().setScrollFactor(0);
this.add(this.panelG);
// RGB channel ghosts (additive strokes) — converge on open, diverge on close.
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]);
// Title (decoded) + body lines.
const famHeader = fontStack('header', FONT_FALLBACK);
const famBody = fontStack('body', FONT_FALLBACK);
this.title = scene.add.text(0, -this.h / 2 + 24, '', {
fontFamily: famHeader,
fontSize: '16px',
color: toCss(c.ink ?? '#eaf6ff'),
letterSpacing: 3,
}).setOrigin(0.5, 0).setScrollFactor(0);
this.add(this.title);
this.bodyTexts = [];
for (let i = 0; i < 2; i++) {
const t = scene.add
.text(0, -this.h / 2 + 52 + i * 17, '', {
fontFamily: famBody,
fontSize: '11px',
color: toCss(c.dim ?? '#7d92c4'),
letterSpacing: 1.5,
})
.setOrigin(0.5, 0)
.setScrollFactor(0);
this.bodyTexts.push(t);
this.add(t);
}
// Buttons — the confirm button is built wide enough for the longest
// label ("OVERWRITE"); show() swaps the actual label in.
this.confirmBtn = new MenuButton(
scene,
w / 2 - 84,
this.h / 2 - 24,
'CONFIRM',
() => this.fireConfirm(),
{ screenFixed: true, fontSize: 12, paddingX: 18, paddingY: 8, width: 148, textColor: c.ink ?? '#eaf6ff', upper: true },
);
this.cancelBtn = new MenuButton(
scene,
-w / 2 + 78,
this.h / 2 - 24,
'CANCEL',
() => this.cancel(),
{ screenFixed: true, fontSize: 12, paddingX: 18, paddingY: 8, width: 104, textColor: c.dim ?? '#7d92c4', upper: true },
);
this.confirmBtn.setAlpha(0);
this.cancelBtn.setAlpha(0);
this.add([this.cancelBtn, this.confirmBtn]);
this._titleDec = null;
this._btnsUp = false;
this._confirmUp = false;
}
// ------------------------------------------------------------------
// Showing
// ------------------------------------------------------------------
/**
* spec: {
* title, body: string[], accent?: number,
* confirmLabel?, cancelLabel?,
* onConfirm?, onCancel? — fired after the close animation lands
* time — the engine time (for the open animation)
* }
*/
show(spec) {
this.accent = toColor(spec.accent ?? this.accent);
this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null;
this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null;
const setBtn = (btn, label) => {
const s = String(label ?? '').toUpperCase();
btn.labelText.setText(s);
btn.ghostCyan.setText(s);
btn.ghostMagenta.setText(s);
};
setBtn(this.confirmBtn, spec.confirmLabel ?? 'CONFIRM');
setBtn(this.cancelBtn, spec.cancelLabel ?? 'CANCEL');
// Accent the confirm button's edge + hover glow.
this.confirmBtn.style.stroke = this.accent;
this.confirmBtn.style.neon = this.accent;
this.confirmBtn.setDisabled(false);
this.cancelBtn.setDisabled(false);
this.title.setColor(toCss(this.accent));
this.bodyTexts.forEach((t, i) => t.setText(spec.body?.[i] ?? ''));
const t = spec.time ?? this.scene.time.now;
this.state = 'opening';
this.t0 = t;
this._titleDec = new ScrambleDecode(String(spec.title ?? ''), t, 420);
this.title.setText('');
this.confirmBtn.setAlpha(0);
this.cancelBtn.setAlpha(0);
this._btnsUp = false;
this._confirmUp = false;
this.scrim.ignorePointer = false;
this.confirmBtn.panel.ignorePointer = false;
this.cancelBtn.panel.ignorePointer = false;
const cfg = config.section('save.confirm.animation', {});
this.openDur = cfg.openMs ?? 170;
this.closeDur = cfg.closeMs ?? 130;
// The scene plays the SFX (one voice, one place).
this.scene.playSfx?.('construct');
}
/** Close + fire onCancel (after the close animation). */
cancel() {
if (this.state === 'hidden' || this.state === 'closing') return;
this.startClose();
this._fired = 'cancel';
}
fireConfirm() {
if (this.state !== 'shown' && this.state !== 'opening') return;
this.startClose();
this._fired = 'confirm';
const fn = this.onConfirm;
this.onConfirm = null;
// Fire when the collapse has landed (or immediately if it's done).
if (this.state === 'hidden') {
fn?.();
} else {
this._pending = fn;
}
}
startClose() {
if (this.state === 'hidden' || this.state === 'closing') return;
this.state = 'closing';
this.t0 = this.lastTime ?? this.scene.time.now;
this.scene.playSfx?.('deconstruct');
}
/** @returns {boolean} true while the dialog is up (blocks the modal behind) */
contains(px, py) {
if (this.state === 'hidden') return false;
const dx = Math.abs(px - this.x);
const dy = Math.abs(py - this.y);
return dx <= this.areaW / 2 && dy <= this.areaH / 2;
}
get isOpen() {
return this.state !== 'hidden';
}
// ------------------------------------------------------------------
// Per-frame (driven by SavePanel.update)
// ------------------------------------------------------------------
update(time) {
this.lastTime = time;
if (this.state === 'hidden') return;
const p = Phaser.Math.Clamp((time - this.t0) / (this.state === 'opening' ? this.openDur : this.closeDur), 0, 1);
const e = p * p * (3 - 2 * p); // smoothstep
const h = this.state === 'opening' ? Math.max(2, e * this.h) : Math.max(2, (1 - e) * this.h);
this.drawPanel(h);
// The world behind goes quiet — the scrim rides the same curve.
this.scrim.setAlpha(0.72 * (this.state === 'opening' ? e : 1 - e));
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
const start = 10;
if (this.state === 'opening') {
const off = start * (1 - e);
const a = 0.5 * (1 - e);
this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3);
this.ghostM.setAlpha(a).setPosition(-off, off * 0.5);
} else {
const off = start * e;
const a = 0.45 * e;
this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3);
this.ghostM.setAlpha(a).setPosition(-off, off * 0.5);
}
// Title decode.
if (this._titleDec) {
if (this._titleDec.finished(time)) {
this.title.setText(this._titleDec.value);
this._titleDec = null;
} else if (this._titleDec.started(time)) {
this.title.setText(this._titleDec.display(time));
}
}
// Buttons flicker up on open (as the panel crosses their band).
if (this.state === 'opening' && p > 0.45) {
if (!this._btnsUp) {
this._btnsUp = true;
this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' });
}
if (p > 0.6 && !this._confirmUp) {
this._confirmUp = true;
this.scene.tweens.add({ targets: this.confirmBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' });
}
}
// The open lands — the dialog is up and waiting.
if (this.state === 'opening' && p >= 1) this.state = 'shown';
if (this.state === 'closing' && p >= 1) {
this.setAlpha(1);
this.scrim.setAlpha(0);
this.panelG.clear();
this.ghostC.setAlpha(0);
this.ghostM.setAlpha(0);
this.confirmBtn.setAlpha(0);
this.cancelBtn.setAlpha(0);
this.title.setText('');
this.bodyTexts.forEach((t) => t.setText(''));
this.state = 'hidden';
this.scrim.ignorePointer = true;
this.confirmBtn.panel.ignorePointer = true;
this.cancelBtn.panel.ignorePointer = true;
const fired = this._fired;
this._fired = null;
const pending = this._pending;
this._pending = null;
this.onConfirm = null;
this.onCancel = null;
if (fired === 'confirm') pending?.();
else if (fired === 'cancel') this.onCancel?.();
}
}
/** The panel body at the current split height h (grows from the center line). */
drawPanel(h) {
const c = config.section('save.colors', {});
const g = this.panelG;
g.clear();
const w = this.w;
const notch = Math.min(14, h * 0.22, w * 0.1);
// Body: deep panel gradient feel (flat fill + edge glow — cheap).
CyberShape.draw(g, w, h, {
notch,
fill: toColor(c.panelTop ?? '#0e1930'),
fillAlpha: 0.97,
stroke: toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: 0.9,
lineWidth: 1.5,
glow: this.accent,
glowAlpha: 0.22,
});
// The split edges — while animating they're the hot lines; at rest
// (h = full) they read as the panel's top/bottom rails.
const edgeA = 0.5 + 0.3 * Math.min(1, h / this.h);
g.fillStyle(toColor(c.neon ?? '#00e5ff'), edgeA * 0.8);
g.fillRect(-w / 2 + notch, -h / 2, w - notch * 2, 1.5);
g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), edgeA * 0.5);
g.fillRect(-w / 2 + notch, h / 2 - 1.5, w - notch * 2, 1.5);
// Ghost strokes at full size (the channels being pulled apart).
for (const [gg, color] of [[this.ghostC, c.neon ?? '#00e5ff'], [this.ghostM, c.magenta ?? '#ff2d6f']]) {
gg.clear();
CyberShape.draw(gg, w, this.h, { notch: Math.min(14, this.h * 0.22), stroke: toColor(color), strokeAlpha: 1, lineWidth: 1.5 });
}
}
destroy() {
// The MenuButtons are children — the container's destroy takes them.
super.destroy();
}
}