347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
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);
|
||
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;
|
||
}
|
||
// ------------------------------------------------------------------
|
||
// 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;
|
||
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?.('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';
|
||
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();
|
||
}
|
||
}
|