From 3b76e96113e51c681d22211366e948cd1b1debc8 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Thu, 17 Jul 2025 18:21:25 -0700 Subject: [PATCH] Add offline play feature --- .gitignore | 2 +- app.js | 436 +++++++ index.html | 1237 +++--------------- install_off.webp | Bin 0 -> 3742 bytes install_on.webp | Bin 0 -> 4020 bytes style.css | 762 +++++++++++ sw.js | 213 ++++ uninstall_off.webp | Bin 0 -> 3580 bytes uninstall_on.webp | Bin 0 -> 4266 bytes workbox/workbox-cacheable-response.dev.js | 176 +++ workbox/workbox-cacheable-response.prod.js | 2 + workbox/workbox-core.dev.js | 1059 ++++++++++++++++ workbox/workbox-core.prod.js | 2 + workbox/workbox-precaching.dev.js | 1201 ++++++++++++++++++ workbox/workbox-precaching.prod.js | 2 + workbox/workbox-range-requests.dev.js | 242 ++++ workbox/workbox-range-requests.prod.js | 2 + workbox/workbox-routing.dev.js | 884 +++++++++++++ workbox/workbox-routing.prod.js | 2 + workbox/workbox-strategies.dev.js | 1334 ++++++++++++++++++++ workbox/workbox-strategies.prod.js | 2 + workbox/workbox-sw.js | 2 + 22 files changed, 6498 insertions(+), 1062 deletions(-) create mode 100644 app.js create mode 100644 install_off.webp create mode 100644 install_on.webp create mode 100644 style.css create mode 100644 sw.js create mode 100644 uninstall_off.webp create mode 100644 uninstall_on.webp create mode 100644 workbox/workbox-cacheable-response.dev.js create mode 100644 workbox/workbox-cacheable-response.prod.js create mode 100644 workbox/workbox-core.dev.js create mode 100644 workbox/workbox-core.prod.js create mode 100644 workbox/workbox-precaching.dev.js create mode 100644 workbox/workbox-precaching.prod.js create mode 100644 workbox/workbox-range-requests.dev.js create mode 100644 workbox/workbox-range-requests.prod.js create mode 100644 workbox/workbox-routing.dev.js create mode 100644 workbox/workbox-routing.prod.js create mode 100644 workbox/workbox-strategies.dev.js create mode 100644 workbox/workbox-strategies.prod.js create mode 100644 workbox/workbox-sw.js diff --git a/.gitignore b/.gitignore index 621c639..b560bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ isle.wasm isle.js - +LEGO diff --git a/app.js b/app.js new file mode 100644 index 0000000..012eb17 --- /dev/null +++ b/app.js @@ -0,0 +1,436 @@ +var Module = { + arguments: ['--ini', '/config/isle.ini'], + running: false, + preRun: function () { + Module["addRunDependency"]("isle"); + Module.running = true; + }, + canvas: (function () { + return document.getElementById('canvas'); + })(), + onExit: function () { + window.location.reload(); + } +}; + +document.addEventListener('DOMContentLoaded', function () { + // --- Elements --- + const video = document.getElementById('install-video'); + const soundToggleEmoji = document.getElementById('sound-toggle-emoji'); + const mainContainer = document.getElementById('main-container'); + const topContent = document.getElementById('top-content'); + const controlsWrapper = document.getElementById('controls-wrapper'); + const footer = document.querySelector('.footer-disclaimer'); + const allPages = document.querySelectorAll('.page-content'); + const pageButtons = document.querySelectorAll('[data-target]'); + const backButtons = document.querySelectorAll('.page-back-button'); + const languageSelect = document.getElementById('language-select'); + const installBtn = document.getElementById('install-btn'); + const uninstallBtn = document.getElementById('uninstall-btn'); + const controlsContainer = document.querySelector('.offline-play-controls'); + + // --- Sound Toggle --- + function updateSoundEmojiState() { + soundToggleEmoji.textContent = video.muted ? '🔇' : '🔊'; + soundToggleEmoji.title = video.muted ? 'Unmute Audio' : 'Mute Audio'; + } + + if (video && soundToggleEmoji) { + updateSoundEmojiState(); + soundToggleEmoji.addEventListener('click', function () { + video.muted = !video.muted; + updateSoundEmojiState(); + }); + video.addEventListener('volumechange', updateSoundEmojiState); + } + + // --- Control Image Hover --- + const imageControls = document.querySelectorAll('.control-img'); + imageControls.forEach(control => { + const hoverImage = new Image(); + if (control.dataset.on) { + hoverImage.src = control.dataset.on; + } + control.addEventListener('mouseover', function () { if (this.dataset.on) { this.src = this.dataset.on; } }); + control.addEventListener('mouseout', function () { if (this.dataset.off) { this.src = this.dataset.off; } }); + }); + + // --- Emscripten Launch Logic --- + const runGameButton = document.getElementById('run-game-btn'); + const emscriptenCanvas = document.getElementById('canvas'); + const canvasWrapper = document.getElementById('canvas-wrapper'); + const loadingGifOverlay = document.getElementById('loading-gif-overlay'); + const statusMessageBar = document.getElementById('emscripten-status-message'); + + runGameButton.addEventListener('click', function () { + if (!Module.running) return; + video.muted = true; + updateSoundEmojiState(); + this.src = this.dataset.on; + + mainContainer.style.display = 'none'; + canvasWrapper.style.display = 'grid'; + + document.documentElement.style.overflow = 'hidden'; + document.documentElement.style.overscrollBehavior = 'none'; + + Module["disableOffscreenCanvases"] ||= document.getElementById('renderer-select').value == "0 0x682656f3 0x0 0x0 0x2000000"; + console.log("disableOffscreenCanvases: " + Module["disableOffscreenCanvases"]); + + Module["removeRunDependency"]("isle"); + emscriptenCanvas.focus(); + }); + + let progressUpdates = 0; + emscriptenCanvas.addEventListener('presenterProgress', function (event) { + // Intro animation is ready + if (event.detail.objectName == 'Lego_Smk' && event.detail.tickleState == 1) { + loadingGifOverlay.style.display = 'none'; + emscriptenCanvas.style.setProperty('display', 'block', 'important'); + } + else if (progressUpdates < 1003) { + progressUpdates++; + const percent = (progressUpdates / 1003 * 100).toFixed(); + statusMessageBar.innerHTML = 'Loading LEGO® Island... please wait! ' + percent + '%'; + } + }); + + // --- Page Navigation Logic --- + function showPage(pageId, pushState = true) { + const page = document.querySelector(pageId); + if (!page) return; + + // Hide main content + topContent.style.display = 'none'; + controlsWrapper.style.display = 'none'; + + // Show selected page + page.style.display = 'flex'; + window.scroll(0, 0); + + if (pushState) { + const newPath = pageId.replace('-page', ''); + history.pushState({ page: pageId }, '', newPath); + } + } + + function showMainMenu() { + // Hide all pages + allPages.forEach(p => p.style.display = 'none'); + + // Show main content + topContent.style.display = 'flex'; + controlsWrapper.style.display = 'flex'; + } + + pageButtons.forEach(button => { + button.addEventListener('click', (e) => { + const targetId = e.currentTarget.dataset.target; + showPage(targetId); + }); + }); + + backButtons.forEach(button => { + button.addEventListener('click', () => { + history.back(); + }); + }); + + window.addEventListener('popstate', (e) => { + if (e.state && e.state.page && e.state.page !== 'main') { + showPage(e.state.page, false); + } else { + showMainMenu(); + } + }); + + // --- OPFS Config Manager --- + const configManager = { + form: document.querySelector('.config-form'), + filePath: 'isle.ini', + + async init() { + if (!this.form) return; + await this.loadConfig(); + this.form.addEventListener('change', () => this.saveConfig()); + }, + + async getFileHandle() { + try { + const root = await navigator.storage.getDirectory(); + return await root.getFileHandle(this.filePath, { create: true }); + } catch (e) { + console.error("OPFS not available or permission denied.", e); + document.getElementById('opfs-disabled').style.display = ''; + document.getElementById('config-form').querySelectorAll('input, select').forEach(element => { + element.disabled = true; + }); + return null; + } + }, + + async saveConfig() { + // This function now uses an inline Web Worker for maximum compatibility, + // especially with Safari, which does not support createWritable(). + + let iniContent = '[isle]\n'; + const elements = this.form.elements; + + for (const element of elements) { + if (!element.name || element.dataset.notIni == "true") continue; + + let value; + switch (element.type) { + case 'checkbox': + value = element.checked ? 'YES' : 'NO'; + iniContent += `${element.name}=${value}\n`; + break; + case 'radio': + if (element.checked) { + value = element.value; + iniContent += `${element.name}=${value}\n`; + } + break; + default: + value = element.value; + iniContent += `${element.name}=${value}\n`; + break; + } + } + + const workerCode = ` + self.onmessage = async (e) => { + if (e.data.action === 'save') { + try { + const root = await navigator.storage.getDirectory(); + const handle = await root.getFileHandle(e.data.filePath, { create: true }); + const accessHandle = await handle.createSyncAccessHandle(); + const encoder = new TextEncoder(); + const encodedData = encoder.encode(e.data.content); + + accessHandle.truncate(0); + accessHandle.write(encodedData, { at: 0 }); + accessHandle.flush(); + accessHandle.close(); + + self.postMessage({ status: 'success', message: 'Config saved to ' + e.data.filePath }); + } catch (err) { + self.postMessage({ status: 'error', message: 'Failed to save config: ' + err.message }); + } + } + }; + `; + + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const workerUrl = URL.createObjectURL(blob); + const worker = new Worker(workerUrl); + + worker.postMessage({ + action: 'save', + content: iniContent, + filePath: this.filePath + }); + + worker.onmessage = (e) => { + console.log(e.data.message); + URL.revokeObjectURL(workerUrl); // Clean up the temporary URL + worker.terminate(); + }; + + worker.onerror = (e) => { + console.error('An error occurred in the config-saving worker:', e.message); + URL.revokeObjectURL(workerUrl); + worker.terminate(); + }; + }, + + async loadConfig() { + const handle = await this.getFileHandle(); + if (!handle) return; + + const file = await handle.getFile(); + const text = await file.text(); + if (!text) { + console.log('No existing config file found, using defaults.'); + await this.saveConfig(); + return; + } + + const config = {}; + const lines = text.split('\n'); + for (const line of lines) { + if (line.startsWith('[') || !line.includes('=')) continue; + const [key, ...valueParts] = line.split('='); + const value = valueParts.join('=').trim(); + config[key.trim()] = value; + } + + this.applyConfigToForm(config); + console.log('Config loaded from', this.filePath); + }, + + applyConfigToForm(config) { + const elements = this.form.elements; + for (const key in config) { + const element = elements[key]; + if (!element) continue; + + const value = config[key]; + + if (element.type === 'checkbox') { + element.checked = (value === 'YES'); + } else if (element.nodeName === 'RADIO') { // radio nodelist + for (const radio of element) { + if (radio.value === value) { + radio.checked = true; + break; + } + } + } + else { + element.value = value; + } + } + } + }; + + // Handle initial page load with a hash + const initialHash = window.location.hash; + if (initialHash) { + const initialPageId = initialHash + '-page'; + if (document.querySelector(initialPageId)) { + const urlPath = window.location.pathname; + history.replaceState({ page: 'main' }, '', urlPath); + showPage(initialPageId, true); + } + } else { + history.replaceState({ page: 'main' }, '', window.location.pathname); + } + + if (document.documentElement.requestFullscreen) { + const fullscreenElement = document.getElementById('window-fullscreen'); + const windowedElement = document.getElementById('window-windowed'); + + fullscreenElement.addEventListener('change', () => { + if (fullscreenElement.checked) { + document.documentElement.requestFullscreen().catch(err => { + console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); + }); + } + }); + + windowedElement.addEventListener('change', () => { + if (windowedElement.checked && document.fullscreenElement) { + document.exitFullscreen(); + } + }); + + // Event listener for changes in fullscreen state (e.g., F11 or Esc key) + document.addEventListener('fullscreenchange', () => { + if (document.fullscreenElement) { + fullscreenElement.checked = true; + } else { + windowedElement.checked = true; + } + }); + } + else { + document.getElementById('window-form').style.display = 'none'; + } + + if (!window.matchMedia('(any-pointer: coarse)').matches) { + document.getElementById('touch-section').style.display = 'none'; + } + + if ('serviceWorker' in navigator) { + Promise.all([ + configManager.init(), + navigator.serviceWorker.register('/sw.js').then(() => navigator.serviceWorker.ready) + ]).then(([configResult, swRegistration]) => { + checkInitialCacheStatus(); + }).catch(error => { + console.error('Initialization failed:', error); + }); + + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + } + else { + configManager.init(); + } + + const progressCircular = document.createElement('div'); + progressCircular.className = 'progress-circular'; + controlsContainer.appendChild(progressCircular); + + installBtn.addEventListener('click', () => { + if (navigator.serviceWorker.controller) { + const selectedLanguage = languageSelect.value; + installBtn.style.display = 'none'; + uninstallBtn.style.display = 'none'; + progressCircular.style.display = 'flex'; + progressCircular.textContent = '0%'; + progressCircular.style.background = 'conic-gradient(#FFD700 0deg, #333 0deg)'; + + navigator.serviceWorker.controller.postMessage({ + action: 'install_language_pack', + language: selectedLanguage + }); + } + }); + + uninstallBtn.addEventListener('click', () => { + const selectedLanguage = languageSelect.value; + navigator.serviceWorker.controller.postMessage({ + action: 'uninstall_language_pack', + language: selectedLanguage + }); + }); + + languageSelect.addEventListener('change', () => { + checkInitialCacheStatus(); + }); + + function checkInitialCacheStatus() { + if (navigator.serviceWorker.controller) { + const selectedLanguage = languageSelect.value; + navigator.serviceWorker.controller.postMessage({ + action: 'check_cache_status', + language: selectedLanguage + }); + } + } + + function updateInstallUI(isInstalled, inProgress = false) { + progressCircular.style.display = inProgress ? 'flex' : 'none'; + installBtn.style.display = !isInstalled && !inProgress ? 'block' : 'none'; + uninstallBtn.style.display = isInstalled && !inProgress ? 'block' : 'none'; + } + + function handleServiceWorkerMessage(event) { + const { action, language, progress, exists, success } = event.data; + if (language !== languageSelect.value) return; + + switch (action) { + case 'cache_status': + updateInstallUI(exists); + break; + case 'install_progress': + updateInstallUI(false, true); + const angle = (progress / 100) * 360; + progressCircular.textContent = `${Math.round(progress)}%`; + progressCircular.style.background = + `radial-gradient(#181818 60%, transparent 61%), conic-gradient(#FFD700 ${angle}deg, #333 ${angle}deg)`; + break; + case 'install_complete': + updateInstallUI(success); + break; + case 'uninstall_complete': + updateInstallUI(!success); + break; + case 'install_failed': + alert('Download failed. Please check your internet connection and try again.'); + updateInstallUI(false); + break; + } + } +}); diff --git a/index.html b/index.html index 9d7c121..85a143d 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,21 @@ + LEGO® Island - - + + + + + + + + @@ -22,642 +31,12 @@ + - - +
@@ -673,31 +52,41 @@
Run Game - Configure - Free Stuff + data-off="run_game_off.webp" data-on="run_game_on.webp"> + Configure + Free Stuff Read Me + data-off="read_me_off.webp" data-on="read_me_on.webp" data-target="#read-me-page"> Cancel + data-off="cancel_off.webp" data-on="cancel_on.webp" onclick="location.href = 'https://legoisland.org';">
- +
← Back

Read Me

-

Welcome to the LEGO Island web port project! This is a recreation of the classic 1997 PC game, rebuilt to run in modern web browsers using Emscripten.

-

This incredible project stands on the shoulders of giants. It was made possible by the original decompilation project, which was then adapted into a portable version. This represents a year-long effort, involving thousands of hours of work from many awesome contributors dedicated to preserving this piece of gaming history.

-

Our goal is to make this classic accessible to everyone. The project is still in development, so you may encounter bugs. Your patience and feedback are greatly appreciated!

+

Welcome to the LEGO Island web port project! This is a recreation of the classic 1997 PC game, + rebuilt to run in modern web browsers using Emscripten.

+

This incredible project stands on the shoulders of giants. It was made possible by the original decompilation project, which was then adapted into a portable version. This represents a year-long effort, involving + thousands of hours of work from many awesome contributors dedicated to preserving this piece of + gaming history.

+

Our goal is to make this classic accessible to everyone. The project is still in development, so you + may encounter bugs. Your patience and feedback are greatly appreciated!

- +
← Back
@@ -710,7 +99,7 @@
- @@ -722,14 +111,15 @@ - +
- +
@@ -742,11 +132,14 @@
- +
@@ -759,11 +152,14 @@
- +
@@ -782,17 +178,20 @@ Touch Control Scheme ? -
Virtual Gamepad (Recommended): Slide your finger to move and turn.

-
Virtual Arrow Keys: Tap screen areas to move. The top moves forward, the bottom turns or moves back.

-
Virtual Mouse: Emulates classic mouse controls with touch.
-
+
Virtual Gamepad (Recommended): Slide your finger to + move and turn.

+
Virtual Arrow Keys: Tap screen areas to move. The top + moves forward, the bottom turns or moves back.

+
Virtual Mouse: Emulates classic mouse controls with + touch.
+
- - +
@@ -800,13 +199,15 @@ Options ? -
Haptic feedback: On supported devices and browsers, this provides physical feedback, like a vibration, while you play the game.
-
+
Haptic feedback: On supported devices and browsers, + this provides physical feedback, like a vibration, while you play the + game.
+
- - + +
@@ -819,7 +220,9 @@
@@ -854,7 +257,9 @@ @@ -863,10 +268,13 @@ - +
@@ -876,44 +284,63 @@
- - +
- - +
-

Sound

-
+

Sound

+
- - + +
- - + +
+
+

Offline Play

+
+
+

Install for Offline Access

+

By installing, the game will be available to play even when you are not connected to + the internet. This will download all necessary files to your device (about 550MB in + size).

+

Note: browsers enforce strict storage and memory quotas, especially when using private/incognito windows. If you encounter an error during installation, please use a regular window and make sure you have enough disk space available on your device.

+
+
+ Install Game + +
+
+
@@ -923,73 +350,104 @@
-

"In November of 2010, after all was said and done, I started getting emails from a few kids and some adults telling me how cool they thought LEGO Island was. Some people actually still play it. I was quite thrilled by these emails and actually quite honored."

+

"In November of 2010, after all was said and done, I started getting emails from a few kids + and some adults telling me how cool they thought LEGO Island was. Some people actually still + play it. I was quite thrilled by these emails and actually quite honored."

- Wes Jenkins, Creative Director
- +

The Making of LEGO Island: A Documentary

-

An in-depth documentary by MattKC that explores the fascinating and chaotic development story behind the classic game.

+

An in-depth documentary by MattKC that explores the fascinating and chaotic development story + behind the classic game.

- +

LEGO Island Radio 24/7

-

Enjoy the iconic, high-quality soundtrack of LEGO Island anytime with this continuous live stream, complete with the original DJ interludes.

+

Enjoy the iconic, high-quality soundtrack of LEGO Island anytime with this continuous live + stream, complete with the original DJ interludes.

- +

LEGO Island Wiki

-

Your ultimate resource for all things LEGO Island. This fan-run wiki contains a wealth of information, research, and details about the game.

+

Your ultimate resource for all things LEGO Island. This fan-run wiki contains a wealth of + information, research, and details about the game.

- +

LEGO Island Decompilation

-

The core open-source project that reverse-engineered the original game, making this web port and other mods possible. Dive into the source code here.

+

The core open-source project that reverse-engineered the original game, making this web port + and other mods possible. Dive into the source code here.

- +

LEGO Island, Portable Version

-

A portable, cross-platform version of the decompilation project which serves as the direct foundation for this web-based port.

+

A portable, cross-platform version of the decompilation project which serves as the direct + foundation for this web-based port.

- +

isle.pizza Frontend

-

The source code for this website! A custom-built frontend for the Emscripten version of the portable decompilation project.

+

The source code for this website! A custom-built frontend for the Emscripten version of the + portable decompilation project.

- +

LEGO Island Rebuilder

-

A powerful launcher and tool for patching and modding the original 1997 PC version of LEGO Island. Essential for play and modding.

+

A powerful launcher and tool for patching and modding the original 1997 PC version of LEGO + Island. Essential for play and modding.

- +

SIEdit

-

A suite of tools developed by the decompilation team for viewing and editing the ".si" script and resource files from the original game.

+

A suite of tools developed by the decompilation team for viewing and editing the ".si" script + and resource files from the original game.

- +

The Making of LEGO Island, a memoir by Wes Jenkins

-

Read the fascinating, incomplete memoir from Creative Director Wes Jenkins, detailing the development process and the team behind the game.

+

Read the fascinating, incomplete memoir from Creative Director Wes Jenkins, detailing the + development process and the team behind the game.

LEGO Island: Free Poster

-

Download a copy of the iconic poster that was originally included with the retail release of the game.

+

Download a copy of the iconic poster that was originally included with the retail release of + the game.

- +

Development Materials Archive

-

Explore a collection of development materials, concept art, and other historical assets from the creation of LEGO Island.

+

Explore a collection of development materials, concept art, and other historical assets from + the creation of LEGO Island.

- +

Video Game Flashback: An Interview with Wes Jenkins

-

A detailed interview with LEGO Island's Creative Director, Wes Jenkins, offering unique insights into the game's production.

+

A detailed interview with LEGO Island's Creative Director, Wes Jenkins, offering unique + insights into the game's production.

- +

LEGO® Island - Behind the Scenes

-

Watch a rare promotional video created during the game's development, showcasing its progress and vision at the time.

+

Watch a rare promotional video created during the game's development, showcasing its progress + and vision at the time.

- +

The Cutting Room Floor

-

Discover unused assets, hidden data, and other secrets left in the retail version of the game. A fascinating look at what might have been.

+

Discover unused assets, hidden data, and other secrets left in the retail version of the + game. A fascinating look at what might have been.

- +

Project Island High Quality Music

-

A complete, high-quality re-digitization of the LEGO Island soundtrack, restored by the game's main composer, Lorin Nelson.

+

A complete, high-quality re-digitization of the LEGO Island soundtrack, restored by the + game's main composer, Lorin Nelson.

- +