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?.('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(); } }