orbit/js/ui/ConfirmOverlay.js

414 lines
16 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 { setInteractiveEnabled } from '../utils/Input.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());
// Armed while shown ONLY (v4: `input.enabled` — the `ignorePointer`
// idiom is a physics-world property and a no-op, which is why this
// invisible scrim used to swallow every click beneath it).
setInteractiveEnabled(this.scrim, false);
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);
// the natural widths (show() grows a button only as wide as its label
// needs — and never leaves it grown for the NEXT dialog, which may
// have a shorter label)
this.confirmBtn.style.baseWidth = this.confirmBtn.style.width;
this.cancelBtn.style.baseWidth = this.cancelBtn.style.width;
this.add([this.cancelBtn, this.confirmBtn]);
// Hidden dialog = inert buttons (same v4 `input.enabled` rule as the
// scrim — otherwise the invisible CONFIRM/CANCEL swallow centre-
// screen clicks while the dialog is closed).
setInteractiveEnabled(this.confirmBtn.panel, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
this._titleDec = null;
this._btnsUp = false;
this._confirmUp = false;
this._layout(); // size/label land in show(); positions derive from w/h
}
/**
* Place the title, body lines and the two buttons for the CURRENT
* w/h + button sizes: the buttons sit in the panel's bottom band
* (CANCEL left, CONFIRM right — each kept inside the panel edge with
* a 20px margin, whatever its label grows it to).
*/
_layout() {
this.title.setPosition(0, -this.h / 2 + 24);
this.bodyTexts.forEach((t, i) => t.setPosition(0, -this.h / 2 + 52 + i * 17));
const margin = 20;
const cw = this.cancelBtn.style.width;
const bw = this.confirmBtn.style.width;
const bh = this.confirmBtn.style.height;
const cy = this.h / 2 - margin - bh / 2;
this.cancelBtn.setPosition(-this.w / 2 + margin + cw / 2, cy);
this.confirmBtn.setPosition(this.w / 2 - margin - bw / 2, cy);
}
/**
* Grow a button's panel to FIT its label (the SET DESTINATION label is
* wider than the fixed 148px the CONFIRM button was built for — the
* text used to run past the panel edge). Re-measures with the button's
* own text style, then repaints + resets the hit area.
*/
_fitBtn(btn, label, minWidth) {
const s = btn.style;
const m = this.scene.add.text(0, 0, String(label), s.textStyle);
const w = Math.max(s.baseWidth ?? s.width, minWidth, Math.ceil(m.width) + 28);
m.destroy();
if (w === s.width) return;
s.width = w;
btn.setSize(w, s.height);
btn.panel.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(-w / 2, -s.height / 2, w, s.height),
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
btn.paint('base');
}
// ------------------------------------------------------------------
// Showing
// ------------------------------------------------------------------
/**
* spec: {
* title, body: string[], accent?: number,
* confirmLabel?, cancelLabel?,
* width?, height? — override the dialog's size for THIS show (the
* SYSTEM tab's SET DESTINATION is wider than AUTOPILOT's ENGAGE —
* its confirm label is longer); the layout re-flows for it,
* 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;
// Per-show size (the SET DESTINATION dialog is wider than ENGAGE).
if (Number(spec.width) > 0) this.w = Number(spec.width);
if (Number(spec.height) > 0) this.h = Number(spec.height);
this.setSize(this.w, this.h);
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');
// The confirm button must FIT its label (the longest one is
// SET DESTINATION) — grow the panel, then place both in the bottom
// band, inside the panel edges.
this._fitBtn(this.confirmBtn, String(spec.confirmLabel ?? 'CONFIRM').toUpperCase(), 110);
this._fitBtn(this.cancelBtn, String(spec.cancelLabel ?? 'CANCEL').toUpperCase(), 88);
this._layout();
// 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;
setInteractiveEnabled(this.scrim, true);
setInteractiveEnabled(this.confirmBtn.panel, true);
setInteractiveEnabled(this.cancelBtn.panel, true);
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?.('ui_close'); // the close tick (deconstruct is the text's sound)
}
/** @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;
// 'shown' is the STEADY state: hold the open values. (Using the close
// math with the open's t0 — the old `state === 'opening' ? … : …` —
// collapsed the panel to a 2px line and dropped the scrim to zero the
// moment the open animation landed, leaving just the floating text and
// buttons over an un-dimmed window.)
const closing = this.state === 'closing';
const p = Phaser.Math.Clamp((time - this.t0) / (closing ? this.closeDur : this.openDur), 0, 1);
const e = p * p * (3 - 2 * p); // smoothstep
const h = closing ? Math.max(2, (1 - e) * this.h) : Math.max(2, e * this.h);
this.drawPanel(h);
// The world behind goes quiet — the scrim rides the same curve.
this.scrim.setAlpha(0.72 * (closing ? 1 - e : e));
// RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close.
const start = 10;
if (!closing) {
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';
setInteractiveEnabled(this.scrim, false);
setInteractiveEnabled(this.confirmBtn.panel, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
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();
}
}