diff --git a/README.md b/README.md
index 4867505..22bf53f 100644
--- a/README.md
+++ b/README.md
@@ -23,17 +23,27 @@ A custom web frontend for the Emscripten port of [isle-portable](https://github.
3. Obtain the game files (`isle.js` and `isle.wasm`) by building the Emscripten version of [isle-portable](https://github.com/isledecomp/isle-portable), then copy them to the project root.
-4. Start the development server:
+4. Set up the LEGO Island game assets:
+ ```bash
+ npm run prepare:assets
+ ```
+ This will prompt you for the path to your LEGO Island installation or mounted ISO and create the necessary symlinks. You can also provide the path directly:
+ ```bash
+ npm run prepare:assets -- -p /path/to/your/LEGO
+ ```
+
+5. Start the development server:
```bash
npm run dev
```
-5. Open the URL shown in the terminal (usually `http://localhost:5173`).
+6. Open the URL shown in the terminal (usually `http://localhost:5173`).
## Scripts
| Command | Description |
|---------|-------------|
+| `npm run prepare:assets` | Set up LEGO Island game assets via symlinks |
| `npm run dev` | Start development server with hot reload |
| `npm run build` | Build for production (outputs to `dist/`) |
| `npm run preview` | Preview the production build locally |
diff --git a/package.json b/package.json
index 88ce108..dda8867 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"build": "vite build && cp isle.js isle.wasm dist/ && node scripts/workbox-inject.js",
"build:ci": "vite build && node scripts/workbox-inject.js",
"check": "svelte-check --fail-on-warnings",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "prepare:assets": "node scripts/prepare.js"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
diff --git a/scripts/prepare.js b/scripts/prepare.js
new file mode 100644
index 0000000..273b0df
--- /dev/null
+++ b/scripts/prepare.js
@@ -0,0 +1,362 @@
+#!/usr/bin/env node
+// Sets up LEGO game assets via symlinks for isle.pizza development
+
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import * as readline from 'node:readline';
+
+// Expand ~ to home directory
+function expandTilde(filePath) {
+ if (filePath.startsWith('~/') || filePath === '~') {
+ return path.join(os.homedir(), filePath.slice(1));
+ }
+ return filePath;
+}
+
+// Required files with correct case (relative to LEGO folder)
+const REQUIRED_FILES = [
+ // Data files
+ 'data/ACT1INF.DTA',
+ 'data/ACT2INF.DTA',
+ 'data/ACT3INF.DTA',
+ 'data/BLDDINF.DTA',
+ 'data/BLDHINF.DTA',
+ 'data/BLDJINF.DTA',
+ 'data/BLDRINF.DTA',
+ 'data/GMAININF.DTA',
+ 'data/HOSPINF.DTA',
+ 'data/ICUBEINF.DTA',
+ 'data/IELEVINF.DTA',
+ 'data/IISLEINF.DTA',
+ 'data/IMAININF.DTA',
+ 'data/IREGINF.DTA',
+ 'data/OBSTINF.DTA',
+ 'data/PMAININF.DTA',
+ 'data/RACCINF.DTA',
+ 'data/RACJINF.DTA',
+ 'data/WORLD.WDB',
+ 'data/testinf.dta',
+ // Script files - root
+ 'Scripts/CREDITS.SI',
+ 'Scripts/INTRO.SI',
+ 'Scripts/NOCD.SI',
+ 'Scripts/SNDANIM.SI',
+ // Script files - subdirectories
+ 'Scripts/Act2/ACT2MAIN.SI',
+ 'Scripts/Act3/ACT3.SI',
+ 'Scripts/Build/COPTER.SI',
+ 'Scripts/Build/DUNECAR.SI',
+ 'Scripts/Build/JETSKI.SI',
+ 'Scripts/Build/RACECAR.SI',
+ 'Scripts/Garage/GARAGE.SI',
+ 'Scripts/Hospital/HOSPITAL.SI',
+ 'Scripts/Infocntr/ELEVBOTT.SI',
+ 'Scripts/Infocntr/HISTBOOK.SI',
+ 'Scripts/Infocntr/INFODOOR.SI',
+ 'Scripts/Infocntr/INFOMAIN.SI',
+ 'Scripts/Infocntr/INFOSCOR.SI',
+ 'Scripts/Infocntr/REGBOOK.SI',
+ 'Scripts/Isle/ISLE.SI',
+ 'Scripts/Isle/JUKEBOX.SI',
+ 'Scripts/Isle/JUKEBOXW.SI',
+ 'Scripts/Police/POLICE.SI',
+ 'Scripts/Race/CARRACE.SI',
+ 'Scripts/Race/CARRACER.SI',
+ 'Scripts/Race/JETRACE.SI',
+ 'Scripts/Race/JETRACER.SI',
+];
+
+// Optional folders - files in these folders are symlinked if present
+const OPTIONAL_FOLDERS = ['extra', 'textures'];
+
+const TARGET_DIR = path.join(process.cwd(), 'LEGO');
+
+// Parse command line arguments
+function parseArgs() {
+ const args = process.argv.slice(2);
+ let sourcePath = null;
+ let force = false;
+
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--path' || args[i] === '-p') {
+ sourcePath = args[i + 1];
+ i++;
+ } else if (args[i] === '--force' || args[i] === '-f') {
+ force = true;
+ } else if (args[i].startsWith('--path=')) {
+ sourcePath = args[i].slice(7);
+ }
+ }
+
+ return { sourcePath, force };
+}
+
+// Create readline interface for prompts
+function createReadline() {
+ return readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+}
+
+// Prompt user for source path
+async function promptForPath(rl) {
+ return new Promise((resolve) => {
+ rl.question('Enter path to your LEGO Island installation or mounted ISO: ', (answer) => {
+ resolve(answer.trim());
+ });
+ });
+}
+
+// Prompt user for confirmation
+async function confirmDeletion(rl) {
+ return new Promise((resolve) => {
+ rl.question('Existing LEGO folder found. Delete and recreate? (y/N): ', (answer) => {
+ resolve(answer.trim().toLowerCase() === 'y');
+ });
+ });
+}
+
+// Check if path exists
+async function pathExists(p) {
+ try {
+ await fs.access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// Recursively scan directory and build case-insensitive file map by filename
+// Also collects files from optional folders (extra, textures)
+async function buildSourceFileMap(sourcePath) {
+ const fileMap = new Map();
+ const optionalFiles = { extra: [], textures: [] };
+
+ async function scanDir(dirPath, inOptionalFolder = null) {
+ let entries;
+ try {
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ const entryFullPath = path.join(dirPath, entry.name);
+ const entryNameLower = entry.name.toLowerCase();
+
+ if (entry.isDirectory()) {
+ // Check if this directory is an optional folder
+ let optFolder = inOptionalFolder;
+ if (!optFolder && (entryNameLower === 'extra' || entryNameLower === 'textures')) {
+ optFolder = entryNameLower;
+ }
+ await scanDir(entryFullPath, optFolder);
+ } else if (entry.isFile()) {
+ // Store lowercase filename as key, actual full path as value
+ fileMap.set(entryNameLower, entryFullPath);
+
+ // Track files in optional folders
+ if (inOptionalFolder) {
+ optionalFiles[inOptionalFolder].push({
+ filename: entry.name,
+ fullPath: entryFullPath,
+ });
+ }
+ }
+ }
+ }
+
+ await scanDir(sourcePath);
+ return { fileMap, optionalFiles };
+}
+
+// Find matching file in source map by filename (case-insensitive)
+function findMatchingFile(fileMap, targetRelativePath) {
+ // Extract just the filename from the target path
+ const filename = path.basename(targetRelativePath).toLowerCase();
+ return fileMap.get(filename) || null;
+}
+
+// Remove existing LEGO folder
+async function removeExistingLEGO() {
+ if (await pathExists(TARGET_DIR)) {
+ await fs.rm(TARGET_DIR, { recursive: true, force: true });
+ }
+}
+
+// Create directory structure
+async function createDirectoryStructure() {
+ const dirs = new Set();
+
+ for (const file of REQUIRED_FILES) {
+ const dir = path.dirname(file);
+ if (dir !== '.') {
+ dirs.add(dir);
+ }
+ }
+
+ for (const dir of dirs) {
+ await fs.mkdir(path.join(TARGET_DIR, dir), { recursive: true });
+ }
+}
+
+// Create symlinks for all required files
+async function createSymlinks(fileMap) {
+ const missing = [];
+ const created = [];
+
+ for (const targetRelPath of REQUIRED_FILES) {
+ const sourcePath = findMatchingFile(fileMap, targetRelPath);
+
+ if (!sourcePath) {
+ missing.push(targetRelPath);
+ continue;
+ }
+
+ const targetPath = path.join(TARGET_DIR, targetRelPath);
+
+ try {
+ await fs.symlink(sourcePath, targetPath);
+ created.push({ target: targetRelPath, source: sourcePath });
+ } catch (err) {
+ if (err.code === 'EPERM' && process.platform === 'win32') {
+ console.error(`\nError: Cannot create symlink (Windows requires Developer Mode or admin privileges)`);
+ console.error(` Enable Developer Mode: Settings -> Update & Security -> For Developers`);
+ process.exit(3);
+ }
+ throw err;
+ }
+ }
+
+ return { missing, created };
+}
+
+// Create symlinks for optional files (extra, textures folders)
+async function createOptionalSymlinks(optionalFiles) {
+ const created = [];
+
+ for (const folder of OPTIONAL_FOLDERS) {
+ const files = optionalFiles[folder] || [];
+ if (files.length === 0) continue;
+
+ // Create the optional folder
+ const folderPath = path.join(TARGET_DIR, folder);
+ await fs.mkdir(folderPath, { recursive: true });
+
+ for (const { filename, fullPath } of files) {
+ const targetRelPath = `${folder}/${filename}`;
+ const targetPath = path.join(TARGET_DIR, targetRelPath);
+
+ try {
+ await fs.symlink(fullPath, targetPath);
+ created.push({ target: targetRelPath, source: fullPath });
+ } catch (err) {
+ if (err.code === 'EPERM' && process.platform === 'win32') {
+ console.error(`\nError: Cannot create symlink (Windows requires Developer Mode or admin privileges)`);
+ console.error(` Enable Developer Mode: Settings -> Update & Security -> For Developers`);
+ process.exit(3);
+ }
+ throw err;
+ }
+ }
+ }
+
+ return created;
+}
+
+// Main entry point
+async function main() {
+ console.log('LEGO Island Asset Setup\n');
+
+ const { sourcePath: argPath, force } = parseArgs();
+ const rl = createReadline();
+
+ try {
+ // Get source path
+ let sourcePath = argPath;
+ if (!sourcePath) {
+ sourcePath = await promptForPath(rl);
+ }
+
+ if (!sourcePath) {
+ console.error('Error: No source path provided');
+ process.exit(1);
+ }
+
+ sourcePath = path.resolve(expandTilde(sourcePath));
+
+ // Validate source path
+ if (!await pathExists(sourcePath)) {
+ console.error(`Error: Source path does not exist: ${sourcePath}`);
+ process.exit(1);
+ }
+
+ console.log(`Source: ${sourcePath}`);
+ console.log(`Target: ${TARGET_DIR}\n`);
+
+ // Build case-insensitive file map from source
+ console.log('Scanning source directory...');
+ const { fileMap, optionalFiles } = await buildSourceFileMap(sourcePath);
+ console.log(`Found ${fileMap.size} files\n`);
+
+ // Check all required files exist before making any changes
+ const missing = REQUIRED_FILES.filter(f => !findMatchingFile(fileMap, f));
+ if (missing.length > 0) {
+ console.error('Error: The following required files were not found:\n');
+ missing.forEach(f => console.error(` - ${f}`));
+ console.error('\nPlease ensure your source path points to a valid LEGO Island installation or mounted ISO.');
+ process.exit(2);
+ }
+
+ // Check if LEGO folder exists and confirm deletion
+ if (await pathExists(TARGET_DIR)) {
+ if (!force) {
+ const confirmed = await confirmDeletion(rl);
+ if (!confirmed) {
+ console.log('Aborted.');
+ process.exit(0);
+ }
+ }
+ console.log('Removing existing LEGO folder...');
+ await removeExistingLEGO();
+ }
+
+ // Create directory structure
+ console.log('Creating directory structure...');
+ await createDirectoryStructure();
+
+ // Create symlinks for required files
+ console.log('Creating symlinks for required files...\n');
+ const result = await createSymlinks(fileMap);
+
+ for (const { target, source } of result.created) {
+ console.log(` ${target} -> ${source}`);
+ }
+
+ // Create symlinks for optional files (extra, textures)
+ console.log('\nCreating symlinks for optional files...\n');
+ const optionalCreated = await createOptionalSymlinks(optionalFiles);
+
+ if (optionalCreated.length > 0) {
+ for (const { target, source } of optionalCreated) {
+ console.log(` ${target} -> ${source}`);
+ }
+ } else {
+ console.log(' (no optional files found)');
+ }
+
+ const totalCreated = result.created.length + optionalCreated.length;
+ console.log(`\nSetup complete! Created ${totalCreated} symlinks (${result.created.length} required, ${optionalCreated.length} optional).`);
+ console.log('You can now run `npm run dev`.');
+
+ } finally {
+ rl.close();
+ }
+}
+
+main().catch(err => {
+ console.error('Unexpected error:', err.message);
+ process.exit(1);
+});
diff --git a/src/app.css b/src/app.css
index 1b14418..c79bf0d 100644
--- a/src/app.css
+++ b/src/app.css
@@ -588,21 +588,27 @@ body {
.config-card-content {
display: grid;
grid-template-rows: 0fr;
- transition: grid-template-rows 0.3s ease;
+ transition: grid-template-rows 0.3s ease, padding-bottom 0.3s ease;
padding: 0 16px;
+ padding-bottom: 0;
}
.config-card-content.open {
grid-template-rows: 1fr;
- padding: 0 16px 16px 16px;
+ padding-bottom: 16px;
}
.config-card-content > div {
+ min-height: 0;
overflow: hidden;
}
+@keyframes enable-tooltip-overflow {
+ to { overflow: visible; }
+}
+
.config-card-content.open > div {
- overflow: visible;
+ animation: enable-tooltip-overflow 0s 0.3s forwards;
}
/* Toggle switches */
diff --git a/src/lib/ConfigurePage.svelte b/src/lib/ConfigurePage.svelte
index 802d02b..c8a0a54 100644
--- a/src/lib/ConfigurePage.svelte
+++ b/src/lib/ConfigurePage.svelte
@@ -210,8 +210,8 @@
-{/if}OPFS is disabled in this browser. Default configuration will apply. If you are using a Firefox - Private window, please change to a regular window instead to change configuration.
+OPFS is disabled in this browser. Default configuration will apply. If you are using a Private/Incognito + window, please change to a regular window instead to change configuration.