diff --git a/assets/images/superkart/backdrop-speedway.png b/assets/images/superkart/backdrop-speedway.png index 0267ca9..cb5af30 100644 Binary files a/assets/images/superkart/backdrop-speedway.png and b/assets/images/superkart/backdrop-speedway.png differ diff --git a/assets/images/superkart/theme-nightcity.png b/assets/images/superkart/theme-nightcity.png new file mode 100644 index 0000000..c4066a8 Binary files /dev/null and b/assets/images/superkart/theme-nightcity.png differ diff --git a/assets/images/superkart/theme-nightcity.psd b/assets/images/superkart/theme-nightcity.psd new file mode 100644 index 0000000..78e8805 Binary files /dev/null and b/assets/images/superkart/theme-nightcity.psd differ diff --git a/assets/images/superkart/theme-scrapyard.png b/assets/images/superkart/theme-scrapyard.png new file mode 100644 index 0000000..b135496 Binary files /dev/null and b/assets/images/superkart/theme-scrapyard.png differ diff --git a/assets/images/superkart/theme-scrapyard.psd b/assets/images/superkart/theme-scrapyard.psd new file mode 100644 index 0000000..0f422ac Binary files /dev/null and b/assets/images/superkart/theme-scrapyard.psd differ diff --git a/assets/images/superkart/theme-speedway.png b/assets/images/superkart/theme-speedway.png new file mode 100644 index 0000000..9049a6f Binary files /dev/null and b/assets/images/superkart/theme-speedway.png differ diff --git a/assets/images/superkart/theme-speedway.psd b/assets/images/superkart/theme-speedway.psd new file mode 100644 index 0000000..c0c2e83 Binary files /dev/null and b/assets/images/superkart/theme-speedway.psd differ diff --git a/assets/images/superkart/theme-swamp.png b/assets/images/superkart/theme-swamp.png new file mode 100644 index 0000000..8e3c986 Binary files /dev/null and b/assets/images/superkart/theme-swamp.png differ diff --git a/assets/images/superkart/theme-swamp.psd b/assets/images/superkart/theme-swamp.psd new file mode 100644 index 0000000..c69b194 Binary files /dev/null and b/assets/images/superkart/theme-swamp.psd differ diff --git a/assets/images/superkart/theme-volcano.png b/assets/images/superkart/theme-volcano.png new file mode 100644 index 0000000..7b15020 Binary files /dev/null and b/assets/images/superkart/theme-volcano.png differ diff --git a/assets/images/superkart/theme-volcano.psd b/assets/images/superkart/theme-volcano.psd new file mode 100644 index 0000000..163626c Binary files /dev/null and b/assets/images/superkart/theme-volcano.psd differ diff --git a/data/superkart-artwork.json b/data/superkart-artwork.json index 6e45e38..84b79a8 100644 --- a/data/superkart-artwork.json +++ b/data/superkart-artwork.json @@ -42,11 +42,11 @@ }, "themeSheets": { "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 }, - "scrapyard": { "key": "superkart-theme-scrapyard", "path": null, "frameWidth": 64, "frameHeight": 64 }, - "swamp": { "key": "superkart-theme-swamp", "path": null, "frameWidth": 64, "frameHeight": 64 }, - "speedway": { "key": "superkart-theme-speedway", "path": null, "frameWidth": 64, "frameHeight": 64 }, - "nightcity": { "key": "superkart-theme-nightcity", "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": "assets/images/superkart/theme-scrapyard.png", "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": "assets/images/superkart/theme-speedway.png", "frameWidth": 64, "frameHeight": 64 }, + "nightcity": { "key": "superkart-theme-nightcity", "path": "assets/images/superkart/theme-nightcity.png", "frameWidth": 64, "frameHeight": 64 } }, "backdrops": { "beach": { "key": "superkart-backdrop-beach", "path": "assets/images/superkart/backdrop-beach.png" }, diff --git a/data/superkart-rules.json b/data/superkart-rules.json index cb3de30..061d14d 100644 --- a/data/superkart-rules.json +++ b/data/superkart-rules.json @@ -15,7 +15,7 @@ "baseTopSpeed": 560, "topSpeedSpread": 160, "baseAccel": 320, - "accelSpread": 240, + "accelSpread": 480, "brakeDecel": 760, "coastDrag": 1.1, "reverseTopSpeed": 170, diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 36c6bc1..06af087 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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(); + }); + }, + }); } } } diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 96bc088..424e78f 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -535,7 +535,7 @@ function completeBuild(rules, state, city) { if (id === 'sscomponent') ship.component += 1; if (id === 'ssmodule') ship.module += 1; 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); return; } diff --git a/src/games/superkart/SuperKartEditor.js b/src/games/superkart/SuperKartEditor.js index 82b99a9..03dda4a 100644 --- a/src/games/superkart/SuperKartEditor.js +++ b/src/games/superkart/SuperKartEditor.js @@ -1,7 +1,8 @@ // Super Kart track editor. Secret entrance: index.html?superkart-editor=1 -// (see PreloadScene). Mirrors the Peggle editor workflow: edit → Test Play → -// ⬇ Export Track (track-NNN.json) + ⬇ Export Cups Index (cups.json), then -// hand-drop the files into assets/gamedata/superkart/. +// (see PreloadScene). Mirrors the Peggle editor workflow: load (existing +// track from assets/gamedata/superkart/ or any local .json) → edit → +// 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 // 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. this.cupsIndex = { version: 1, cups: [], tracks: [] }; fetch(`${GAMEDATA}/cups.json`).then((r) => r.json()) - .then((d) => { this.cupsIndex = d; }) + .then((d) => { this.cupsIndex = d; this.refreshLoadOptions(); }) .catch(() => {}); this.buildToolbar(); + this.buildLoadPanel(); this.bindPointer(); this.bindKeys(); this.rebuildModel(); @@ -213,6 +215,107 @@ export default class SuperKartEditor extends Phaser.Scene { 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 = ` +
+
LOAD TRACK
+ + +
+
`; + + 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 = [``, + ...(this.cupsIndex?.tracks ?? []).map((t) => ``)].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 ──────────────────────────────────────────────────────────────── bindPointer() { diff --git a/src/scenes/GameMenuScene.js b/src/scenes/GameMenuScene.js index b26553e..71fcfbf 100644 --- a/src/scenes/GameMenuScene.js +++ b/src/scenes/GameMenuScene.js @@ -14,7 +14,7 @@ const CATEGORIES = [ { key: 'casino', label: 'Casino' }, { key: 'word', label: 'Words & Numbers' }, { 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 }; diff --git a/src/scenes/ProfileScene.js b/src/scenes/ProfileScene.js index 3d1d3ad..836973d 100644 --- a/src/scenes/ProfileScene.js +++ b/src/scenes/ProfileScene.js @@ -5,6 +5,7 @@ import { auth } from '../services/auth.js'; import { Button } from '../ui/Button.js'; import { TextInput } from '../ui/TextInput.js'; import { Modal } from '../ui/Modal.js'; +import { downloadProfile, parseExportFile, applyImportedProfile } from '../services/profileTransfer.js'; export default class ProfileScene extends Phaser.Scene { constructor() { super('Profile'); } @@ -15,6 +16,8 @@ export default class ProfileScene extends Phaser.Scene { 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', { fontFamily: 'Righteous', fontSize: '64px', @@ -37,6 +40,15 @@ export default class ProfileScene extends Phaser.Scene { } 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) const avatarX = cx - 480; const avatarY = 380; @@ -50,10 +62,15 @@ export default class ProfileScene extends Phaser.Scene { void avatarBg; 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', () => { - const img = this.add.image(avatarX, avatarY, `avatar-${profile.id}`); - img.setDisplaySize(180, 180); + const maskG = this.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillCircle(avatarX, avatarY, 96); + this.add.image(avatarX, avatarY, avatarKey) + .setDisplaySize(192, 192) + .setMask(maskG.createGeometryMask()); }); 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); 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 { const { profile: updated } = await api.patch('/profile', { 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 Button(this, cx + 200, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); + } - new Button(this, cx + 100, 900, 'Upload avatar', () => this.pickAvatar()); - new Button(this, cx + 400, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); + handleDownloadProfile() { + 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() { diff --git a/src/services/profileTransfer.js b/src/services/profileTransfer.js new file mode 100644 index 0000000..8c128a0 --- /dev/null +++ b/src/services/profileTransfer.js @@ -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(); +} diff --git a/src/ui/Modal.js b/src/ui/Modal.js index e98b5d8..b8fa802 100644 --- a/src/ui/Modal.js +++ b/src/ui/Modal.js @@ -1,16 +1,20 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; +import { Button } from './Button.js'; export class Modal extends Phaser.GameObjects.Container { constructor(scene, message, options = {}) { super(scene, 0, 0); + const hasButtons = Array.isArray(options.buttons) && options.buttons.length > 0; + const panelHeight = hasButtons ? 320 : 280; const overlay = scene.add.rectangle( GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6, ).setInteractive(); 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); - 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"', fontSize: '28px', color: options.color ?? COLORS.textHex, @@ -24,7 +28,20 @@ export class Modal extends Phaser.GameObjects.Container { if (domLayer) domLayer.style.visibility = 'hidden'; 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(); }); } else { overlay.on('pointerdown', () => { restore(); this.destroy(); });