feat: add Super Kart themes, profile import/export, and Civilization spaceship notifications

- Super Kart: populate missing theme image paths, increase acceleration spread,
  and add a track loading panel to the editor
- Civilization: display completion notifications for spaceship parts with
  direct navigation to the building city
- Profile: implement export/import via .fcg files with validation, download,
  and restore flows
- UI: add multi-button modal support, improve ProfileScene layout with avatar
  masking and info card, and rename Arcade category to "Video Games"
This commit is contained in:
Brian Fertig 2026-07-18 12:28:22 -06:00
parent 61924c439f
commit 54a9614464
20 changed files with 318 additions and 21 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

View File

@ -42,11 +42,11 @@
}, },
"themeSheets": { "themeSheets": {
"beach": { "key": "superkart-theme-beach", "path": "assets/images/superkart/theme-beach.png", "frameWidth": 64, "frameHeight": 64 }, "beach": { "key": "superkart-theme-beach", "path": "assets/images/superkart/theme-beach.png", "frameWidth": 64, "frameHeight": 64 },
"volcano": { "key": "superkart-theme-volcano", "path": null, "frameWidth": 64, "frameHeight": 64 }, "volcano": { "key": "superkart-theme-volcano", "path": "assets/images/superkart/theme-volcano.png", "frameWidth": 64, "frameHeight": 64 },
"scrapyard": { "key": "superkart-theme-scrapyard", "path": null, "frameWidth": 64, "frameHeight": 64 }, "scrapyard": { "key": "superkart-theme-scrapyard", "path": "assets/images/superkart/theme-scrapyard.png", "frameWidth": 64, "frameHeight": 64 },
"swamp": { "key": "superkart-theme-swamp", "path": null, "frameWidth": 64, "frameHeight": 64 }, "swamp": { "key": "superkart-theme-swamp", "path": "assets/images/superkart/theme-swamp.png", "frameWidth": 64, "frameHeight": 64 },
"speedway": { "key": "superkart-theme-speedway", "path": null, "frameWidth": 64, "frameHeight": 64 }, "speedway": { "key": "superkart-theme-speedway", "path": "assets/images/superkart/theme-speedway.png", "frameWidth": 64, "frameHeight": 64 },
"nightcity": { "key": "superkart-theme-nightcity", "path": null, "frameWidth": 64, "frameHeight": 64 } "nightcity": { "key": "superkart-theme-nightcity", "path": "assets/images/superkart/theme-nightcity.png", "frameWidth": 64, "frameHeight": 64 }
}, },
"backdrops": { "backdrops": {
"beach": { "key": "superkart-backdrop-beach", "path": "assets/images/superkart/backdrop-beach.png" }, "beach": { "key": "superkart-backdrop-beach", "path": "assets/images/superkart/backdrop-beach.png" },

View File

@ -15,7 +15,7 @@
"baseTopSpeed": 560, "baseTopSpeed": 560,
"topSpeedSpread": 160, "topSpeedSpread": 160,
"baseAccel": 320, "baseAccel": 320,
"accelSpread": 240, "accelSpread": 480,
"brakeDecel": 760, "brakeDecel": 760,
"coastDrag": 1.1, "coastDrag": 1.1,
"reverseTopSpeed": 170, "reverseTopSpeed": 170,

View File

@ -1454,6 +1454,24 @@ export default class CivilizationGame extends Phaser.Scene {
}); });
}, },
}); });
} else if (e.type === 'spaceshipPart' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const part = this.rules.units[e.part];
if (!city || !part) continue;
this.announceStatus(`${part.name} completed in ${city.name}!`, undefined,
(proceed) => { this.view.panToTile(city.x, city.y); this.time.delayedCall(500, proceed); },
{
label: 'VIEW CITY',
onClick: () => {
this.modalOpen = true;
openCityScreen(this, this.rules, this.state, city, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
this.showNextStatus();
});
},
});
} }
} }
} }

View File

@ -535,7 +535,7 @@ function completeBuild(rules, state, city) {
if (id === 'sscomponent') ship.component += 1; if (id === 'sscomponent') ship.component += 1;
if (id === 'ssmodule') ship.module += 1; if (id === 'ssmodule') ship.module += 1;
city.shieldBox = 0; city.shieldBox = 0;
state.events.push({ type: 'spaceshipPart', civ: city.civ, part: id }); state.events.push({ type: 'spaceshipPart', civ: city.civ, cityId: city.id, part: id });
pickNextBuild(rules, state, city); pickNextBuild(rules, state, city);
return; return;
} }

View File

@ -1,7 +1,8 @@
// Super Kart track editor. Secret entrance: index.html?superkart-editor=1 // Super Kart track editor. Secret entrance: index.html?superkart-editor=1
// (see PreloadScene). Mirrors the Peggle editor workflow: edit → Test Play → // (see PreloadScene). Mirrors the Peggle editor workflow: load (existing
// ⬇ Export Track (track-NNN.json) + ⬇ Export Cups Index (cups.json), then // track from assets/gamedata/superkart/ or any local .json) → edit →
// hand-drop the files into assets/gamedata/superkart/. // Test Play → ⬇ Export Track (track-NNN.json) + ⬇ Export Cups Index
// (cups.json), then hand-drop the files into assets/gamedata/superkart/.
// //
// The track is a closed centerline spline with per-point width; item rows and // The track is a closed centerline spline with per-point width; item rows and
// boost pads are arc-length addressed so they ride along with spline edits. // boost pads are arc-length addressed so they ride along with spline edits.
@ -69,10 +70,11 @@ export default class SuperKartEditor extends Phaser.Scene {
// cups.json is lazy-loaded by the game, so the editor fetches it itself. // cups.json is lazy-loaded by the game, so the editor fetches it itself.
this.cupsIndex = { version: 1, cups: [], tracks: [] }; this.cupsIndex = { version: 1, cups: [], tracks: [] };
fetch(`${GAMEDATA}/cups.json`).then((r) => r.json()) fetch(`${GAMEDATA}/cups.json`).then((r) => r.json())
.then((d) => { this.cupsIndex = d; }) .then((d) => { this.cupsIndex = d; this.refreshLoadOptions(); })
.catch(() => {}); .catch(() => {});
this.buildToolbar(); this.buildToolbar();
this.buildLoadPanel();
this.bindPointer(); this.bindPointer();
this.bindKeys(); this.bindKeys();
this.rebuildModel(); this.rebuildModel();
@ -213,6 +215,107 @@ export default class SuperKartEditor extends Phaser.Scene {
this.helpText?.setText(help[id] ?? ''); this.helpText?.setText(help[id] ?? '');
} }
// ── Load ─────────────────────────────────────────────────────────────────
buildLoadPanel() {
const rx = BOARD_X + BOARD_SIZE + 90 + 250 + 40;
const rw = 460;
const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #ffd028; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;';
const el = document.createElement('div');
el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`;
el.innerHTML = `
<div style="background:#0a0a12ee; border:2px solid #ffd028; border-radius:14px; padding:16px 20px;">
<div style="font-size:20px; color:#ffd028; text-align:center; margin-bottom:10px;">LOAD TRACK</div>
<select id="sk-load" style="${input} margin-bottom:10px;"><option value=""> load from ${GAMEDATA}/ </option></select>
<input id="sk-fileinput" type="file" accept=".json" style="width:100%; font-size:14px; color:${COLORS.textHex};">
<div id="sk-load-warn" style="color:${COLORS.dangerHex}; font-size:14px; min-height:20px; margin-top:8px;"></div>
</div>`;
this.loadDom = this.add.dom(rx + rw / 2, 220, el).setDepth(20);
const q = (id) => el.querySelector(id);
this.loadSelect = q('#sk-load');
this.loadWarn = q('#sk-load-warn');
this.loadSelect.addEventListener('change', (ev) => {
const file = ev.target.value;
if (file) this.loadFromManifest(file);
ev.target.value = '';
});
q('#sk-fileinput').addEventListener('change', (ev) => {
const f = ev.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
try { this.loadTrack(JSON.parse(reader.result), f.name); } catch (_) { this.flashLoadWarn(`Could not parse ${f.name}`); }
};
reader.readAsText(f);
ev.target.value = '';
});
this.refreshLoadOptions();
}
refreshLoadOptions() {
if (!this.loadSelect) return;
const opts = [`<option value="">— load from ${GAMEDATA}/ —</option>`,
...(this.cupsIndex?.tracks ?? []).map((t) => `<option value="${t.file}">${t.id} · ${t.name}</option>`)].join('');
this.loadSelect.innerHTML = opts;
}
flashLoadWarn(msg) {
if (!this.loadWarn) return;
this.loadWarn.textContent = msg;
this.time.delayedCall(4000, () => { if (this.loadWarn) this.loadWarn.textContent = ''; });
}
async loadFromManifest(file) {
try {
const res = await fetch(`${GAMEDATA}/${file}`);
if (!res.ok) throw new Error('not found');
this.loadTrack(await res.json(), file);
} catch (_) {
this.flashLoadWarn(`Could not load ${file}`);
}
}
loadTrack(rawData, fileName) {
let data;
try { data = JSON.parse(JSON.stringify(rawData)); } catch (_) { data = null; }
if (!data || !Array.isArray(data.spline) || data.spline.length < 3) {
this.flashLoadWarn(`${fileName}: not a valid track file`);
return;
}
this.pushUndo();
const d = this.defaultTrack();
this.track = {
version: data.version ?? 1,
id: data.id ?? d.id,
name: data.name ?? d.name,
theme: this.themes.includes(data.theme) ? data.theme : (this.themes?.[0] ?? 'speedway'),
laps: data.laps ?? d.laps,
world: data.world ?? d.world,
spline: data.spline,
startIndex: Phaser.Math.Clamp(data.startIndex ?? 0, 0, data.spline.length - 1),
surfaces: data.surfaces ?? [],
walls: data.walls ?? [],
boosts: data.boosts ?? [],
itemRows: data.itemRows ?? [],
hazards: data.hazards ?? [],
decor: data.decor ?? [],
coins: data.coins ?? [],
};
this.selected = -1;
this.draftPoly = [];
this.metaName?.setLabel(`Name: ${this.track.name}`);
this.metaFile?.setLabel(`File: ${this.track.id}.json`);
this.metaTheme?.setLabel(`Theme: ${this.track.theme}`);
this.metaLaps?.setLabel(`Laps: ${this.track.laps}`);
this.rebuildModel();
playSound(this, SFX.UI_CHIME);
this.flash(`Loaded ${fileName}`);
}
// ── Input ──────────────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────────────
bindPointer() { bindPointer() {

View File

@ -14,7 +14,7 @@ const CATEGORIES = [
{ key: 'casino', label: 'Casino' }, { key: 'casino', label: 'Casino' },
{ key: 'word', label: 'Words & Numbers' }, { key: 'word', label: 'Words & Numbers' },
{ key: 'logic', label: 'Logic & Puzzle' }, { key: 'logic', label: 'Logic & Puzzle' },
{ key: 'arcade-console-pc', label: 'Arcade, Console & PC' }, { key: 'arcade-console-pc', label: 'Video Games' },
]; ];
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4, 'arcade-console-pc': 5 }; const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4, 'arcade-console-pc': 5 };

View File

@ -5,6 +5,7 @@ import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { TextInput } from '../ui/TextInput.js'; import { TextInput } from '../ui/TextInput.js';
import { Modal } from '../ui/Modal.js'; import { Modal } from '../ui/Modal.js';
import { downloadProfile, parseExportFile, applyImportedProfile } from '../services/profileTransfer.js';
export default class ProfileScene extends Phaser.Scene { export default class ProfileScene extends Phaser.Scene {
constructor() { super('Profile'); } constructor() { super('Profile'); }
@ -15,6 +16,8 @@ export default class ProfileScene extends Phaser.Scene {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
this.add.text(cx, 140, 'Profile', { this.add.text(cx, 140, 'Profile', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '64px', fontSize: '64px',
@ -37,6 +40,15 @@ export default class ProfileScene extends Phaser.Scene {
} }
this.statusText.destroy(); this.statusText.destroy();
// Soft card behind the profile info so text stays readable over the
// light background image — large radius + shadow instead of a hard box.
const infoCard = this.add.graphics();
infoCard.postFX.addShadow(0, 6, 0.006, 3, 0x000000, 14, 0.55);
infoCard.fillStyle(COLORS.panel, 0.82);
infoCard.fillRoundedRect(320, 230, 1080, 630, 32);
infoCard.lineStyle(2, COLORS.accent, 0.45);
infoCard.strokeRoundedRect(320, 230, 1080, 630, 32);
// Avatar slot (placeholder vector) // Avatar slot (placeholder vector)
const avatarX = cx - 480; const avatarX = cx - 480;
const avatarY = 380; const avatarY = 380;
@ -50,10 +62,15 @@ export default class ProfileScene extends Phaser.Scene {
void avatarBg; void avatarBg;
if (profile.avatarPath) { if (profile.avatarPath) {
this.load.image(`avatar-${profile.id}`, profile.avatarPath); const avatarKey = `avatar-${profile.id}`;
this.load.image(avatarKey, profile.avatarPath);
this.load.once('complete', () => { this.load.once('complete', () => {
const img = this.add.image(avatarX, avatarY, `avatar-${profile.id}`); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
img.setDisplaySize(180, 180); maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarX, avatarY, 96);
this.add.image(avatarX, avatarY, avatarKey)
.setDisplaySize(192, 192)
.setMask(maskG.createGeometryMask());
}); });
this.load.start(); this.load.start();
} }
@ -115,7 +132,14 @@ export default class ProfileScene extends Phaser.Scene {
this.add.text(cx - 320, 680, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5); this.add.text(cx - 320, 680, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const bioInput = new TextInput(this, cx + 80, 740, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 }); const bioInput = new TextInput(this, cx + 80, 740, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
new Button(this, cx - 200, 900, 'Save profile', async () => { // Download profile sits to the right of the profile picture.
new Button(this, cx + 400, avatarY + 50, 'Download Profile', () => this.handleDownloadProfile(), { width: 360 });
// Upload Profile takes the slot next to the picture; Save profile moves to the bottom row.
new Button(this, cx + 400, avatarY - 50, 'Upload Profile', () => this.pickProfileFile(), { variant: 'ghost', width: 360 });
new Button(this, cx - 400, 900, 'Upload avatar', () => this.pickAvatar());
new Button(this, cx - 100, 900, 'Save profile', async () => {
try { try {
const { profile: updated } = await api.patch('/profile', { const { profile: updated } = await api.patch('/profile', {
displayName: displayNameInput.value, displayName: displayNameInput.value,
@ -127,9 +151,61 @@ export default class ProfileScene extends Phaser.Scene {
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 }); new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
} }
}); });
new Button(this, cx + 200, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
new Button(this, cx + 100, 900, 'Upload avatar', () => this.pickAvatar()); handleDownloadProfile() {
new Button(this, cx + 400, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); try {
downloadProfile();
new Modal(this, 'Profile downloaded.', { autoCloseMs: 1500 });
} catch {
new Modal(this, 'Failed to download profile.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
}
}
pickProfileFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.fcg,application/json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
let text;
try {
text = await file.text();
} catch {
new Modal(this, 'Could not read that file.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
return;
}
let payload;
try {
payload = parseExportFile(text);
} catch (err) {
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2800 });
return;
}
this.confirmImport(payload);
};
input.click();
}
confirmImport(payload) {
new Modal(
this,
"This will overwrite all local save data on this device (chips, history, and every game's progress) with the contents of this file. This cannot be undone. Continue?",
{
buttons: [
{
label: 'Confirm',
onClick: () => {
new Modal(this, 'Profile restored. Reloading…', { autoCloseMs: 900 });
this.time.delayedCall(900, () => applyImportedProfile(payload));
},
},
{ label: 'Cancel', variant: 'ghost', onClick: () => {} },
],
},
);
} }
pickAvatar() { pickAvatar() {

View File

@ -0,0 +1,83 @@
// Packages/restores the entire local save state (localStorage) so a player's
// progress can be moved between browsers/devices via a downloadable .fcg file.
export const EXPORT_APP_ID = 'fertig-classic-games';
export const EXPORT_FORMAT_VERSION = 1;
export function buildExportPayload() {
const keys = {};
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
keys[key] = window.localStorage.getItem(key);
}
return {
app: EXPORT_APP_ID,
formatVersion: EXPORT_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
keys,
};
}
// MM-DD-YY, local time (not UTC).
export function buildExportFilename(date = new Date()) {
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const yy = String(date.getFullYear() % 100).padStart(2, '0');
return `FERTIG-CLASSIC-GAMES-${mm}-${dd}-${yy}.fcg`;
}
export function downloadProfile() {
const payload = buildExportPayload();
const blob = new Blob([`${JSON.stringify(payload, null, 2)}\n`], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = buildExportFilename();
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
// Checks a parsed JSON value actually looks like one of our exports.
export function validateExportPayload(parsed) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { valid: false, reason: 'That file is not a valid profile export.' };
}
if (parsed.app !== EXPORT_APP_ID) {
return { valid: false, reason: 'That file was not exported from Fertig Classic Games.' };
}
if (typeof parsed.formatVersion !== 'number' || parsed.formatVersion > EXPORT_FORMAT_VERSION) {
return { valid: false, reason: 'This profile file was created by a newer, unsupported version of the app.' };
}
if (!parsed.keys || typeof parsed.keys !== 'object' || Array.isArray(parsed.keys)) {
return { valid: false, reason: 'That file is missing profile data.' };
}
for (const [k, v] of Object.entries(parsed.keys)) {
if (typeof k !== 'string' || typeof v !== 'string') {
return { valid: false, reason: 'That file is not a valid profile export.' };
}
}
return { valid: true };
}
// Parses raw file text and validates it. Throws an Error with a
// user-displayable message on failure; returns the payload on success.
export function parseExportFile(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('That file is not valid JSON.');
}
const result = validateExportPayload(parsed);
if (!result.valid) throw new Error(result.reason);
return parsed;
}
// Overwrites localStorage with the payload's keys, then reloads the page so
// every scene/game re-initializes against the restored data.
export function applyImportedProfile(payload) {
window.localStorage.clear();
for (const [k, v] of Object.entries(payload.keys)) {
window.localStorage.setItem(k, v);
}
window.location.reload();
}

View File

@ -1,16 +1,20 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from './Button.js';
export class Modal extends Phaser.GameObjects.Container { export class Modal extends Phaser.GameObjects.Container {
constructor(scene, message, options = {}) { constructor(scene, message, options = {}) {
super(scene, 0, 0); super(scene, 0, 0);
const hasButtons = Array.isArray(options.buttons) && options.buttons.length > 0;
const panelHeight = hasButtons ? 320 : 280;
const overlay = scene.add.rectangle( const overlay = scene.add.rectangle(
GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6,
).setInteractive(); ).setInteractive();
const panel = scene.add.rectangle( const panel = scene.add.rectangle(
GAME_WIDTH / 2, GAME_HEIGHT / 2, 720, 280, COLORS.panel, 1, GAME_WIDTH / 2, GAME_HEIGHT / 2, 720, panelHeight, COLORS.panel, 1,
).setStrokeStyle(2, COLORS.accent); ).setStrokeStyle(2, COLORS.accent);
const text = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 30, message, { const textY = hasButtons ? GAME_HEIGHT / 2 - 60 : GAME_HEIGHT / 2 - 30;
const text = scene.add.text(GAME_WIDTH / 2, textY, message, {
fontFamily: '"Julius Sans One"', fontFamily: '"Julius Sans One"',
fontSize: '28px', fontSize: '28px',
color: options.color ?? COLORS.textHex, color: options.color ?? COLORS.textHex,
@ -24,7 +28,20 @@ export class Modal extends Phaser.GameObjects.Container {
if (domLayer) domLayer.style.visibility = 'hidden'; if (domLayer) domLayer.style.visibility = 'hidden';
const restore = () => { if (domLayer) domLayer.style.visibility = ''; }; const restore = () => { if (domLayer) domLayer.style.visibility = ''; };
if (options.autoCloseMs) { if (hasButtons) {
const n = options.buttons.length;
const spacing = 260;
const startX = GAME_WIDTH / 2 - ((n - 1) * spacing) / 2;
options.buttons.forEach((btn, i) => {
const button = new Button(scene, startX + i * spacing, GAME_HEIGHT / 2 + 70, btn.label, () => {
restore();
this.destroy();
btn.onClick?.();
}, { variant: btn.variant ?? 'solid' });
this.add(button);
});
// No overlay-click-to-dismiss and no auto-close: the user must choose an option.
} else if (options.autoCloseMs) {
scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); }); scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
} else { } else {
overlay.on('pointerdown', () => { restore(); this.destroy(); }); overlay.on('pointerdown', () => { restore(); this.destroy(); });