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"
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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(); });
|
||||||
|
|
|
||||||