diff --git a/.editorconfig b/.editorconfig
index 747df6cde..8f5d758a4 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -35,6 +35,7 @@ indent_size = 1
# Credits file
[credits.txt]
+charset = UTF-8
indent_style = space
indent_size = 2
diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml
new file mode 100644
index 000000000..086d62cf3
--- /dev/null
+++ b/.github/workflows/browser.yml
@@ -0,0 +1,73 @@
+name: browser
+
+on:
+ push:
+ pull_request:
+ types:
+ - synchronize
+ - opened
+
+env:
+ EM_VERSION: 6.0.6
+ EM_CACHE_FOLDER: 'emsdk-cache'
+
+jobs:
+ build:
+ environment: aws-deploy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: ccache
+ uses: hendrikmuhs/ccache-action@v1
+ with:
+ key: ${{ runner.os }}-${{ env.EM_VERSION }}
+
+ - name: Cache emscripten
+ id: cache-emscripten
+ uses: actions/cache@v4
+ env:
+ cache-name: cache-emscripten
+ with:
+ path: emsdk
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.EM_VERSION }}
+
+ - name: install emsdk
+ if: steps.cache-emscripten.outputs.cache-hit != 'true'
+ run: |
+ git clone https://github.com/emscripten-core/emsdk.git
+ ./emsdk/emsdk install ${{ env.EM_VERSION }}
+ ./emsdk/emsdk activate ${{ env.EM_VERSION }}
+
+ - name: Cache libjpegturbo
+ id: cache-libjpegturbo
+ uses: actions/cache@v4
+ env:
+ cache-name: cache-libjpegturbo
+ with:
+ path: |
+ libjpeg-turbo-2.1.0/
+ 2.1.0.tar.gz
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.EM_VERSION }}
+
+ - name: Cache build directory
+ id: cache-build
+ uses: actions/cache@v4
+ env:
+ cache-name: cache-build
+ with:
+ path: build
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.EM_VERSION }}
+
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: us-east-1
+
+ - name: Build
+ run: source emsdk/emsdk_env.sh; make output/index.html
+ - name: Deploy
+ if: ${{ github.ref == 'refs/heads/endless-web' && github.event_name == 'push' }}
+ run: source emsdk/emsdk_env.sh; make deploy
diff --git a/.gitignore b/.gitignore
index 658a2fc4c..da5d1af8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,3 +75,17 @@ CMakeUserPresets.json
# static code analysis
/.scannerwork
+
+# Emscripten web build artifacts
+endless-sky.js
+endless-sky.js.prehash
+to-be-modified-endless-sky.*
+endless-sky.data
+endless-sky.wasm
+endless-sky.wasm.map
+dataversion.js
+output/
+2.1.0.tar.gz
+libjpeg-turbo-2.1.0/
+Ubuntu-Regular.ttf
+favicon.ico
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..8e613d3d6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,153 @@
+EMSCRIPTEN_ENV := $(shell command -v emmake 2> /dev/null)
+CXX := $(shell command -v ccache 2> /dev/null > /dev/null && echo ccache em++ || echo em++)
+
+all: dev
+clean:
+ rm -f endless-sky.js
+ rm -f endless-sky.data
+ rm -f endless-sky.wasm
+ rm -f dataversion.js
+ rm -rf output
+ rm -f endless-sky.wasm.map
+ rm -f lib/emcc/libendless-sky.a
+ rm -rf build/emcc
+# favicon.ico and Ubuntu-Regular.ttf are downloaded, not built. Removing them in
+# 'clean' means any local serve after a clean silently loses the font until
+# something re-fetches them, so they are only removed by 'distclean'.
+distclean: clean
+ rm -rf lib/emcc
+ rm -rf libjpeg-turbo-2.1.0
+ rm -f favicon.ico
+ rm -f Ubuntu-Regular.ttf
+2.1.0.tar.gz:
+ wget -nv https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.1.0.tar.gz
+libjpeg-turbo-2.1.0: 2.1.0.tar.gz
+ tar xzf 2.1.0.tar.gz
+libjpeg-turbo-2.1.0/libturbojpeg.a: | libjpeg-turbo-2.1.0
+ifndef EMSCRIPTEN_ENV
+ $(error "emmake is not available, activate the emscripten env first")
+endif
+ # ENABLE_SHARED=0: without it libjpeg-turbo also builds libjpeg.so, which '-l jpeg'
+ # prefers over the static archive. Since emscripten 6.0.0 disabled FAKE_DYLIBS by
+ # default that .so is a real dynamic library, which implies -sMAIN_MODULE=2 and
+ # fails the link with "relocation ... cannot be used against symbol; recompile with -fPIC".
+ cd libjpeg-turbo-2.1.0; emcmake cmake -G"Unix Makefiles" -DENABLE_SHARED=0 -DWITH_SIMD=0 -DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -Wno-dev
+ cd libjpeg-turbo-2.1.0; sed 's/SIZEOF_SIZE_T [0-9]*/SIZEOF_SIZE_T 4/' jconfigint.h > jconfigint.h.tmp && mv jconfigint.h.tmp jconfigint.h
+ cd libjpeg-turbo-2.1.0; emmake $(MAKE)
+dev: endless-sky.js dataversion.js Ubuntu-Regular.ttf favicon.ico
+ emrun --serve_after_close --serve_after_exit --browser chrome --private_browsing endless-sky.html
+Ubuntu-Regular.ttf:
+ curl -Ls 'https://github.com/google/fonts/blob/main/ufl/ubuntu/Ubuntu-Regular.ttf?raw=true' > Ubuntu-Regular.ttf
+favicon.ico:
+ wget -nv https://endless-sky.github.io/favicon.ico
+
+COMMON_FLAGS = -O3\
+ -s USE_SDL=2\
+ -s USE_LIBPNG=1\
+ -s DISABLE_EXCEPTION_CATCHING=0
+
+CFLAGS = $(COMMON_FLAGS)\
+ -s USE_ZLIB=1\
+ -Duuid_generate_random=uuid_generate\
+ -std=c++20\
+ -Wall\
+ -Werror\
+ -Wold-style-cast\
+ -DES_GLES\
+ -gsource-map\
+ -I libjpeg-turbo-2.1.0\
+
+LINK_FLAGS = $(COMMON_FLAGS)\
+ -s USE_ZLIB=1\
+ -L libjpeg-turbo-2.1.0\
+ -l jpeg\
+ -lopenal\
+ -lidbfs.js\
+ --source-map-base http://localhost:6931/\
+ -s USE_WEBGL2=1\
+ -s ASSERTIONS=2\
+ -s GL_ASSERTIONS=1\
+ -s ASYNCIFY\
+ -s MIN_WEBGL_VERSION=2\
+ -s MAX_WEBGL_VERSION=2\
+ -s MAXIMUM_MEMORY=2147483648\
+ -s INITIAL_MEMORY=1347289088\
+ -s ALLOW_MEMORY_GROWTH=1\
+ --preload-file data\
+ --preload-file images\
+ --preload-file sounds\
+ --preload-file shaders\
+ --preload-file credits.txt\
+ --preload-file keys.txt\
+ -s EXPORTED_RUNTIME_METHODS=['callMain']\
+ --emrun
+
+# Source files: all .cpp in source subdirs except test/, windows/, and excluded audio suppliers
+# Also exclude ZipFile.cpp (no minizip in emscripten)
+CPPS := $(filter-out source/ZipFile.cpp,$(shell ls source/*.cpp)) \
+ $(shell ls source/text/*.cpp) \
+ $(shell ls source/ship/*.cpp) \
+ $(shell ls source/audio/*.cpp) \
+ $(shell ls source/audio/player/*.cpp) \
+ $(shell ls source/audio/supplier/AudioSupplier.cpp source/audio/supplier/WavSupplier.cpp source/audio/supplier/effect/Fade.cpp) \
+ $(shell ls source/image/*.cpp) \
+ $(shell ls source/shader/*.cpp) \
+ $(shell ls source/comparators/*.cpp 2>/dev/null) \
+ $(shell ls source/orders/*.cpp 2>/dev/null) \
+ $(shell ls source/test/*.cpp 2>/dev/null)
+CPPS_EXCEPT_MAIN := $(filter-out source/main.cpp,$(CPPS))
+TEMP := $(subst source/,build/emcc/,$(CPPS))
+OBJS := $(subst .cpp,.o,$(TEMP))
+TEMP := $(subst source/,build/emcc/,$(CPPS_EXCEPT_MAIN))
+OBJS_EXCEPT_MAIN := $(subst .cpp,.o,$(TEMP))
+HEADERS := $(shell find source -name '*.h' -o -name '*.hpp' | grep -v windows/)
+
+BUILD_DIRS := build/emcc build/emcc/text build/emcc/ship build/emcc/audio \
+ build/emcc/audio/player build/emcc/audio/supplier build/emcc/audio/supplier/effect \
+ build/emcc/image build/emcc/shader build/emcc/comparators build/emcc/orders build/emcc/test
+
+build/emcc/%.o: source/%.cpp $(HEADERS) libjpeg-turbo-2.1.0/libturbojpeg.a
+ @mkdir -p $(BUILD_DIRS)
+ $(CXX) $(CFLAGS) -c $< -o $@
+
+lib/emcc/libendless-sky.a: $(OBJS_EXCEPT_MAIN)
+ @mkdir -p lib/emcc
+ emar rcs lib/emcc/libendless-sky.a $(OBJS_EXCEPT_MAIN)
+
+endless-sky.js: Makefile libjpeg-turbo-2.1.0/libturbojpeg.a lib/emcc/libendless-sky.a build/emcc/main.o
+ifndef EMSCRIPTEN_ENV
+ $(error "em++ is not available, activate the emscripten env first")
+endif
+ em++ -o endless-sky.js $(LINK_FLAGS) build/emcc/main.o lib/emcc/libendless-sky.a
+
+dataversion.js: endless-sky.js
+ ./hash-data.py endless-sky.data dataversion.js
+output/index.html: endless-sky.js endless-sky.html favicon.ico Ubuntu-Regular.ttf dataversion.js js/cached-resource.js js/plugins.js js/save-games.js
+ rm -rf output
+ mkdir -p output
+ cp endless-sky.html to-be-modified-endless-sky.html
+ cp endless-sky.js to-be-modified-endless-sky.js
+ ./copy-to-hashed-location.py endless-sky.wasm endless-sky.data output/
+ mkdir output/js
+ ./copy-to-hashed-location.py js/* output/
+ ./copy-to-hashed-location.py dataversion.js output/
+ ./copy-to-hashed-location.py loading.mp3 output/
+ ./copy-to-hashed-location.py Ubuntu-Regular.ttf output/
+ cp favicon.ico output/
+ # Hash endless-sky.js only after its .wasm and .data references have been
+ # rewritten, so the filename reflects the bytes that actually ship. Hashing it
+ # beforehand meant two builds differing only in C++ produced an identical
+ # filename of identical length, so 'aws s3 sync --size-only' silently skipped
+ # uploading the newer one and the site kept loading the previous wasm.
+ mv endless-sky.js endless-sky.js.prehash
+ mv to-be-modified-endless-sky.js endless-sky.js
+ ./copy-to-hashed-location.py endless-sky.js output/
+ mv endless-sky.js.prehash endless-sky.js
+ mv to-be-modified-endless-sky.html output/index.html
+test: output/index.html
+ cd output; emrun --serve_after_close --serve_after_exit --browser chrome --private_browsing index.html
+deploy: output/index.html
+ aws s3 sync --size-only --exclude index.html output s3://play-endless-sky.com/live --cache-control 'public, max-age=604800, immutable'
+ aws s3 sync --exclude '*' --include index.html output s3://play-endless-sky.com/live --cache-control 'max-age=0'
+ aws cloudfront create-invalidation --distribution-id E2TZUW922XPLEF --paths / /index.html
+ aws cloudfront create-invalidation --distribution-id E3D0Y4DMGSVPWC --paths / /index.html
diff --git a/copy-to-hashed-location.py b/copy-to-hashed-location.py
new file mode 100755
index 000000000..dc5ea6264
--- /dev/null
+++ b/copy-to-hashed-location.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+
+import shutil
+import os
+from pathlib import Path
+
+
+def copy_and_add_hash(target_directory, source, files_to_modify):
+ import hashlib
+ hash_md5 = hashlib.md5()
+ length = 0
+ with open(source, "rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ length += len(chunk)
+ hash_md5.update(chunk)
+ hash = hash_md5.hexdigest()
+ if '.' in os.path.basename(source):
+ base, extensions = os.path.basename(source).split('.', 1)
+ extensions = '.' + extensions
+ else:
+ base = source
+ extensions = ''
+ dest = base + '-' + hash + extensions
+ copy_dest = os.path.join(target, dest)
+ print(source, '->', dest)
+
+ total_count = 0
+ for to_modify in files_to_modify:
+ with open(to_modify, "r+") as f:
+ data = f.read()
+ f.seek(0)
+ count = data.count(source)
+ if count:
+ print("replacing", count, "occurrence" + ('' if count == 1 else 's'), "of", source, 'in', to_modify)
+ output = data.replace(source, dest)
+ f.write(output)
+ f.truncate()
+ total_count += count
+ if total_count == 0:
+ raise ValueError(repr(files_to_modify) + " do not contain source '"+source+"' to modify!")
+
+ shutil.copy(source, copy_dest)
+
+if __name__ == '__main__':
+ FILES_TO_MODIFY = [
+ "to-be-modified-endless-sky.html",
+ "to-be-modified-endless-sky.js"
+ ]
+
+ import sys
+ target = sys.argv[-1]
+ if target[-1] == '/':
+ target = target[:-1]
+ sources = sys.argv[1:-1]
+ if not os.path.isdir(target):
+ raise ValueError("dest must be a directory")
+ # endless-sky.js is hashed last, after it has itself been rewritten and moved
+ # into place, so by then it is no longer available to be modified. Skip any
+ # file that has already been consumed, but require at least one to remain.
+ FILES_TO_MODIFY = [f for f in FILES_TO_MODIFY if os.path.exists(f)]
+ if not FILES_TO_MODIFY:
+ raise ValueError("none of the files to modify exist")
+ for source in sources:
+ copy_and_add_hash(target, source, FILES_TO_MODIFY)
diff --git a/credits.txt b/credits.txt
index 529469bad..394557af2 100644
--- a/credits.txt
+++ b/credits.txt
@@ -33,6 +33,10 @@ Developers
W1zrad
Zitchas
+WebAssembly Port
+ MichaĆ Janiszewski (janisozaur)
+ Thomas Ballinger
+
Major Programming
AdamKauffman
Amazinite
diff --git a/docs/readme-developer.md b/docs/readme-developer.md
index 4f56cb78d..9e51d794c 100644
--- a/docs/readme-developer.md
+++ b/docs/readme-developer.md
@@ -215,3 +215,25 @@ $ cmake -G Xcode --preset macos # macos-arm for Apple Silicon
```
The XCode project is located in the `build/` directory.
+
+## Building for the web
+
+Mac and Linux (Windows not supported):
+
+Install Emscripten following the instructions at https://emscripten.org/docs/getting_started/downloads.html
+Use the latest version and source the emsdk_env.sh file so you can run commands like emcc, em++ and emmake.
+The last time I checked, this looked like:
+
+```
+ $ git clone https://github.com/emscripten-core/emsdk.git
+ $ cd emsdk
+ $ ./emsdk install 6.0.6
+ $ ./emsdk activate 6.0.6
+ $ source ./emsdk_env.sh # you'll need to run this one each time you open a new terminal
+```
+
+Now back in the endless-sky repo directory run: (maybe you need to install make, wget, and tar first? I figure those should be everywhere already)
+
+```
+ $ make dev
+```
diff --git a/endless-sky.html b/endless-sky.html
new file mode 100644
index 000000000..d910342c4
--- /dev/null
+++ b/endless-sky.html
@@ -0,0 +1,593 @@
+
+
+
+
+
+
+
+
+
+ Endless Sky
+
+
+
+
+
+
+
E·n·d·l·e·s·s W·e·b
+
+
click anywhere to play loading music
+
Downloading data...
+
+
+
+
+
+
+
Plugins
+
+
+
Debug panel
+
press backslash to hide
+
upload saved game
+
+
+
+
+
+
Status goes here
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hash-data.py b/hash-data.py
new file mode 100755
index 000000000..20e4f488c
--- /dev/null
+++ b/hash-data.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+
+def create_data_version_javascript(target, source):
+ import hashlib
+ hash_md5 = hashlib.md5()
+ length = 0
+ with open(str(source), "rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ length += len(chunk)
+ hash_md5.update(chunk)
+ hash = hash_md5.hexdigest()
+
+ with open(str(target), 'w') as f:
+ f.write('// autogenerated file, do not edit\n')
+ f.write('// This is the md5 hash of the data file expected\n')
+ f.write('var endlessSkyDataVersion = "')
+ f.write(hash)
+ f.write('";\nvar endlessSkyDataSize = ')
+ f.write(str(length))
+ f.write(';\n')
+
+if __name__ == '__main__':
+ import sys
+ source, target = sys.argv[1:]
+ create_data_version_javascript(target, source)
diff --git a/js/cached-resource.js b/js/cached-resource.js
new file mode 100644
index 000000000..4714bdc1a
--- /dev/null
+++ b/js/cached-resource.js
@@ -0,0 +1,127 @@
+"use strict";
+
+(function (exports) {
+ // A simple key value store using IndexedDB because it allows storage of
+ // large amounts of data.
+ class KeyValueStore {
+ constructor(dbName, storeName) {
+ this.storeName = storeName;
+ this.dbPromise = new Promise((resolve, reject) => {
+ const dbRequest = indexedDB.open(dbName, 1);
+ dbRequest.onerror = () => reject(dbRequest.error);
+ dbRequest.onsuccess = () => resolve(dbRequest.result);
+ dbRequest.onupgradeneeded = () => {
+ dbRequest.result.createObjectStore(storeName);
+ };
+ });
+ }
+ get(key) {
+ return this.dbPromise.then(
+ (db) =>
+ new Promise((resolve, reject) => {
+ const transaction = db.transaction(this.storeName, "readonly");
+ const req = transaction.objectStore(this.storeName).get(key);
+ transaction.oncomplete = () => resolve(req.result);
+ transaction.onabort = transaction.onerror = () => {
+ reject(transaction.error);
+ };
+ })
+ );
+ }
+ set(key, value) {
+ return this.dbPromise.then(
+ (db) =>
+ new Promise((resolve, reject) => {
+ const transaction = db.transaction(this.storeName, "readwrite");
+ const req = transaction.objectStore(this.storeName).put(value, key);
+ transaction.oncomplete = () => resolve(req.result);
+ transaction.onabort = transaction.onerror = () => {
+ reject(transaction.error);
+ };
+ })
+ );
+ }
+ }
+
+ const kvstore = new KeyValueStore("data", "store");
+
+ // Caches a single version of a resource
+ class CachedResource {
+ constructor(resourceUrl, key, keySuffix) {
+ if (keySuffix === undefined) keySuffix = "";
+ this.resourceUrl = resourceUrl;
+ this.cacheKey = key + keySuffix;
+ }
+ async get(length, version, progressCallback = () => {}) {
+ const cachedData = await kvstore.get(this.cacheKey);
+ const cachedVersion = await kvstore.get(this.cacheKey + "-version");
+ if (cachedData) {
+ if (version === cachedVersion) {
+ console.log(
+ "Using cached resource",
+ this.cacheKey,
+ "originally downloaded from",
+ this.resourceUrl
+ );
+ progressCallback(cachedData.byteLength, cachedData.byteLength, true);
+ console.log("cached data:", cachedData);
+ return cachedData;
+ }
+ console.log(
+ "Out of date resource",
+ this.cacheKey,
+ "so redownloading from",
+ this.resourceUrl
+ );
+ console.log(
+ "required version",
+ version,
+ "but had version",
+ cachedVersion,
+ "stored"
+ );
+ }
+ let data;
+
+ const response = await fetch(this.resourceUrl);
+ const headerContentLength = parseInt(
+ response.headers.get("Content-Length")
+ );
+ // ContentLength header is not reliable: it might be the length of the compressed resource.
+ // response.body is missing in Pale Moon, a Firefox fork that someone on the Endless Sky Discord server uses
+ if (length && response.body) {
+ // use a progress bar
+ let offset = 0;
+ data = new ArrayBuffer(length);
+ const view = new Uint8Array(data);
+ progressCallback(offset, length);
+ const reader = response.body.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ view.set(value, offset);
+ offset += value.length;
+ progressCallback(offset, length);
+ }
+ progressCallback(offset, length);
+ } else {
+ // no progress bar
+ progressCallback(0, headerContentLength);
+ data = await response.arrayBuffer();
+ }
+
+ console.log("downloaded", this.resourceUrl, data);
+ try {
+ await kvstore.set(this.cacheKey, data);
+ await kvstore.set(this.cacheKey + "-version", version);
+ } catch (e) {
+ console.log(
+ "Failure writing to IndexedDB, maybe private browsing / incognito mode or low on disk space"
+ );
+ }
+ return data;
+ }
+ }
+
+ window.CachedResource = CachedResource;
+})();
diff --git a/js/plugins.js b/js/plugins.js
new file mode 100644
index 000000000..9657ae823
--- /dev/null
+++ b/js/plugins.js
@@ -0,0 +1,304 @@
+"use strict";
+
+(function (exports) {
+ function rmdashr(path) {
+ const f = FS.open(path);
+ const paths = Object.keys(f.node.contents);
+ FS.close(f);
+ if (f.node.isFolder) {
+ for (const childPath of paths) rmdashr(path + "/" + childPath);
+ FS.rmdir(path);
+ } else {
+ FS.unlink(path);
+ }
+ }
+
+ function showPlugins(container, plugins) {
+ container.innerHTML = "";
+ container.style.display = "flex";
+ container.style.flexDirection = "column";
+ container.style.height = "30%";
+ container.style.backgroundColor = "grey";
+ container.style.overflow = "scroll";
+ plugins.forEach(
+ ({ name, url, version, author, iconUrl, description }, i) => {
+ // TODO figure out a way to show icons (proxy?)
+ const row = document.createElement("div");
+ row.style.display = "flex";
+ row.style.alignItems = "center";
+ row.style.flexBasis = 1;
+ row.style.flexGrow = 1;
+ row.style.flexShrink = 1;
+ row.style.padding = "5px";
+ row.innerHTML = `
+
+
+ link
+
+
+ `;
+
+ let toDelete = null;
+
+ // add dynamic values using DOM apis to prevent XSS injection
+ row.querySelector("input").value = url;
+ row.querySelector(".plugin-title").textContent = name;
+ row.querySelector(".plugin-link").href = url;
+ row.querySelector(".plugin-description").textContent = description;
+ row.querySelector(".plugin-author").textContent = author;
+
+ const proxy = "https://temp-cors-proxy.herokuapp.com/";
+ const githubZip = `${proxy}${url}`;
+ container.appendChild(row);
+
+ row
+ .querySelector("input")
+ .addEventListener("change", async function (e) {
+ if (this.checked) {
+ this.disabled = true; // disable until download complete
+
+ const data = await new Promise(function (resolve, reject) {
+ JSZipUtils.getBinaryContent(githubZip, function (err, data) {
+ if (err) reject(err);
+ else resolve(data);
+ });
+ });
+ const jszip = await JSZip.loadAsync(data);
+ for (const name of Object.keys(jszip.files)) {
+ if (!toDelete) toDelete = name;
+ const zipObj = jszip.files[name];
+ if (zipObj.dir) {
+ const path = ("/plugins/" + name).slice(0, -1); // remove trailing slash
+ FS.mkdir(path);
+ continue;
+ } else {
+ const ab = await zipObj.async("ArrayBuffer");
+ const stream = FS.open("/plugins/" + name, "w+");
+ FS.write(stream, new Uint8Array(ab), 0, ab.byteLength, 0);
+ FS.close(stream);
+ }
+ }
+ this.disabled = false;
+ } else {
+ const path = `/plugins/${toDelete}`;
+ rmdashr(path);
+ }
+ });
+ }
+ );
+ }
+
+ async function getAllFileEntries(dataTransferItemList) {
+ let fileEntries = [];
+ let queue = [];
+ for (let i = 0; i < dataTransferItemList.length; i++) {
+ queue.push(dataTransferItemList[i].webkitGetAsEntry());
+ }
+ while (queue.length > 0) {
+ let entry = queue.shift();
+ if (entry.isFile) {
+ fileEntries.push(entry);
+ } else if (entry.isDirectory) {
+ queue.push(...(await readAllDirectoryEntries(entry.createReader())));
+ }
+ }
+ return fileEntries;
+ }
+
+ async function readAllDirectoryEntries(directoryReader) {
+ let entries = [];
+ let readEntries = await readEntriesPromise(directoryReader);
+ while (readEntries.length > 0) {
+ entries.push(...readEntries);
+ readEntries = await readEntriesPromise(directoryReader);
+ }
+ return entries;
+ }
+
+ async function readEntriesPromise(directoryReader) {
+ try {
+ return await new Promise((resolve, reject) => {
+ directoryReader.readEntries(resolve, reject);
+ });
+ } catch (err) {
+ console.log(err);
+ }
+ }
+
+ function showPluginUpload(container) {
+ container.innerHTML = "";
+ container.style.display = "flex";
+ container.style.flexDirection = "column";
+ container.style.backgroundColor = "grey";
+ container.style.overflow = "scroll";
+
+ container.innerHTML = `
+
+
+ `;
+ const input = container.querySelector("input");
+ const dropTarget = container.querySelector("#drop-target");
+
+ function parentDirsFromRoot(path) {
+ const dirs = [];
+ let parent = "";
+ const parts = path.split("/");
+ for (const part of parts.slice(0, parts.length - 1)) {
+ if (!part) continue; // slash always already exists
+ parent = parent + "/" + part;
+ dirs.push(parent);
+ }
+ return dirs;
+ }
+
+ // intermediate representation so this works with drag and drop
+ // (which uses fileentries) and input (which uses files)
+ async function addPluginFiles(filesAndPaths) {
+ for (const { file, path } of filesAndPaths) {
+ // TODO I think these are always unix-like / paths? check Windows
+ const dest = "/plugins" + (path[0] === "/" ? path : "/" + path);
+ for (const dir of parentDirsFromRoot(dest)) {
+ if (!FS.analyzePath(dir).exists) {
+ FS.mkdir(dir);
+ }
+ }
+ const ab = await file.arrayBuffer();
+ const stream = FS.open(dest, "w+");
+ FS.write(stream, new Uint8Array(ab), 0, ab.byteLength, 0);
+ FS.close(stream);
+ console.log("wrote uploaded data to", dest);
+ }
+
+ input.style.display = "none";
+ container.innerHTML =
+ "Uploaded " + filesAndPaths.length + " plugin files";
+ }
+
+ input.addEventListener("input", async (e) => {
+ // TODO does this always contain all files?
+ await addPluginFiles(
+ [...e.target.files].map((f) => ({
+ path: f.webkitRelativePath,
+ file: f,
+ }))
+ );
+ });
+
+ function restoreTinyDropzone() {
+ dropTarget.style.outline = "";
+ dropTarget.style.zIndex = "";
+ dropTarget.style.top = "";
+ dropTarget.style.left = "";
+ dropTarget.style.width = "";
+ dropTarget.style.height = "";
+ dropTarget.style.position = "";
+ dropTarget.style.opacity = "";
+ dropTarget.style.backgroundColor = "";
+ }
+
+ dropTarget.addEventListener(
+ "drop",
+ async function (event) {
+ event.preventDefault();
+ restoreTinyDropzone();
+ container.querySelector("input").files = event.dataTransfer.files;
+ const all = await getAllFileEntries(event.dataTransfer.items);
+ addPluginFiles(
+ await Promise.all(
+ all.map(async (fileEntry) => {
+ return {
+ path: fileEntry.fullPath,
+ file: await new Promise((r) => fileEntry.file(r)),
+ };
+ })
+ )
+ );
+ },
+ false
+ );
+ document.querySelector("html").addEventListener("dragenter", (e) => {
+ dropTarget.style.outline = "solid 5px blue";
+ dropTarget.style.zIndex = 100;
+ dropTarget.style.top = "0";
+ dropTarget.style.left = "0";
+ dropTarget.style.width = "100vw";
+ dropTarget.style.height = "100vh";
+ dropTarget.style.position = "absolute";
+ dropTarget.style.opacity = "0.5";
+ dropTarget.style.backgroundColor = "grey";
+ e.preventDefault();
+ });
+ dropTarget.addEventListener("dragenter", (e) => {
+ e.preventDefault();
+ });
+ dropTarget.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ });
+ dropTarget.addEventListener("dragleave", (e) => {
+ restoreTinyDropzone();
+ });
+ }
+
+ function showPluginsForDownload(container) {
+ const now = new Date();
+ const contents = FS.lookupPath("plugins").node.contents;
+ const plugins = Object.keys(contents).map((name) => {
+ const path = `/plugins/${name}`;
+ return {
+ name,
+ path: `/plugins/${name}`,
+ };
+ });
+
+ container.innerHTML = "";
+ plugins.forEach(({ name, path }) => {
+ const button = document.createElement("button");
+ button.class = "download-button";
+ button.innerText = `${name}`;
+ function zipName(path) {
+ return path.replace("plugins/", "");
+ }
+ button.onclick = async function offerFileAsDownload() {
+ const archive = new JSZip();
+
+ const frontier = [path];
+
+ while (frontier.length) {
+ const path = frontier.pop();
+ const node = FS.lookupPath(path).node;
+ if (node.isFolder) {
+ for (const [name, _childNode] of Object.entries(node.contents)) {
+ frontier.push(path + "/" + name);
+ }
+ archive.folder(zipName(path));
+ continue;
+ }
+ // TODO preserve data modified etc.
+ // TODO preserve folders (they get lost on upload)
+ archive.file(zipName(path), FS.lookupPath(path).node.contents);
+ }
+
+ const downloadable = await archive.generateAsync({
+ type: "blob",
+ });
+
+ const a = document.createElement("a");
+ a.download = name;
+ a.href = URL.createObjectURL(downloadable);
+ a.style.display = "none";
+
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(a.href);
+ }, 10000);
+ };
+ container.appendChild(button);
+ });
+ }
+
+ window.showPluginsForDownload = showPluginsForDownload;
+ window.showPlugins = showPlugins;
+ window.showPluginUpload = showPluginUpload;
+})();
diff --git a/js/save-games.js b/js/save-games.js
new file mode 100644
index 000000000..654231e3b
--- /dev/null
+++ b/js/save-games.js
@@ -0,0 +1,58 @@
+"use strict";
+
+(function (exports) {
+ function timeDelta(seconds) {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor(seconds / 60);
+ const s = Math.ceil(seconds % 60);
+ if (h) return h > 1 ? `${h} hours ago` : `1 hour ago`;
+ if (m) return m > 1 ? `${m} minutes ago` : `1 minute ago`;
+ return s < 20 ? `just now` : `${s} seconds ago`;
+ }
+
+ function showSaveGames(container) {
+ const now = new Date();
+ const contents = FS.lookupPath("saves").node.contents;
+ const files = Object.keys(contents).map((name) => {
+ const path = `/saves/${name}`;
+ const mtime = FS.stat(path).mtime;
+ return {
+ name,
+ path: `/saves/${name}`,
+ mtime,
+ t: +mtime,
+ secondsAgo: Math.ceil((now - mtime) / 1000),
+ };
+ });
+ files.sort((a, b) => a.secondsAgo - b.secondsAgo);
+
+ container.innerHTML = "";
+ files.forEach(({ name, path, secondsAgo }) => {
+ const button = document.createElement("button");
+ button.class = "download-button";
+ button.innerText = `${name} (saved ${timeDelta(secondsAgo)})`;
+ button.onclick = function offerFileAsDownload() {
+ const mime = "text/plain";
+ let content = FS.readFile(path);
+ console.log(
+ `Offering download of "${path}", with ${content.length} bytes...`
+ );
+
+ const a = document.createElement("a");
+ a.download = name;
+ a.href = URL.createObjectURL(new Blob([content], { type: mime }));
+ a.style.display = "none";
+
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(a.href);
+ }, 10000);
+ };
+ container.appendChild(button);
+ });
+ }
+
+ window.showSaveGames = showSaveGames;
+})();
diff --git a/loading.mp3 b/loading.mp3
new file mode 100644
index 000000000..b47a3d0a3
Binary files /dev/null and b/loading.mp3 differ
diff --git a/source/Files.cpp b/source/Files.cpp
index 410510059..8801008c6 100644
--- a/source/Files.cpp
+++ b/source/Files.cpp
@@ -16,7 +16,9 @@ this program. If not, see .
#include "Files.h"
#include "Logger.h"
+#ifndef __EMSCRIPTEN__
#include "ZipFile.h"
+#endif
#include
@@ -63,6 +65,16 @@ namespace {
#endif
}
+#ifdef __EMSCRIPTEN__
+ // No zip file support needed in the web build; data files are preloaded
+ // into Emscripten's virtual filesystem as loose files.
+ struct ZipFile {
+ vector ListFiles(const filesystem::path &, bool, bool) { return {}; }
+ bool Exists(const filesystem::path &) { return false; }
+ string ReadFile(const filesystem::path &) { return {}; }
+ };
+ shared_ptr GetZipFile(const filesystem::path &) { return {}; }
+#else
/// The open zip files per thread. Since ZLIB doesn't support multithreaded access on the same zip handle,
/// each file is opened multiple times on demand.
thread_local map> OPEN_ZIP_FILES;
@@ -91,6 +103,7 @@ namespace {
return {};
}
+#endif
}
@@ -109,6 +122,10 @@ void Files::Init(const char *const *argv)
}
+#ifdef __EMSCRIPTEN__
+ resources = "/";
+ config = "/";
+#else
if(resources.empty())
{
// Find the path to the resource directory. This will depend on the
@@ -146,11 +163,13 @@ void Files::Init(const char *const *argv)
throw runtime_error("Unable to find the resource directories!");
resources = resources.parent_path();
}
+#endif // __EMSCRIPTEN__
dataPath = resources / "data";
imagePath = resources / "images";
soundPath = resources / "sounds";
globalPluginPath = resources / "plugins";
+#ifndef __EMSCRIPTEN__
if(config.empty())
{
// Create the directory for the saved games, preferences, etc., if necessary.
@@ -165,6 +184,7 @@ void Files::Init(const char *const *argv)
throw runtime_error("Unable to create config directory!");
config = filesystem::canonical(config);
+#endif
savePath = config / "saves";
CreateFolder(savePath);
diff --git a/source/GameWindow.cpp b/source/GameWindow.cpp
index ddf2d5050..e5290f38a 100644
--- a/source/GameWindow.cpp
+++ b/source/GameWindow.cpp
@@ -32,9 +32,11 @@ this program. If not, see .
using namespace std;
namespace {
+#ifndef __EMSCRIPTEN__
// The minimal screen resolution requirements.
constexpr int minWidth = 1024;
constexpr int minHeight = 768;
+#endif
SDL_Window *mainWindow = nullptr;
SDL_GLContext context = nullptr;
@@ -118,11 +120,13 @@ bool GameWindow::Init(bool headless)
// Make the window just slightly smaller than the monitor resolution.
int maxWidth = mode.w;
int maxHeight = mode.h;
+#ifndef __EMSCRIPTEN__
if(maxWidth < minWidth || maxHeight < minHeight)
Logger::Log("Monitor resolution is too small! Minimal requirement is "
+ to_string(minWidth) + 'x' + to_string(minHeight)
+ ", while your resolution is " + to_string(maxWidth) + 'x' + to_string(maxHeight) + '.',
Logger::Level::WARNING);
+#endif
int windowWidth = maxWidth - 100;
int windowHeight = maxHeight - 100;
@@ -452,6 +456,7 @@ void GameWindow::ToggleBlockScreenSaver()
void GameWindow::ExitWithError(const string &message, bool doPopUp)
{
// Print the error message in the terminal and the error file.
+ printf("Error: %s\n", message.c_str());
Logger::Log(message, Logger::Level::ERROR);
checkSDLerror();
diff --git a/source/LoadPanel.cpp b/source/LoadPanel.cpp
index dd3d174ff..c16527b34 100644
--- a/source/LoadPanel.cpp
+++ b/source/LoadPanel.cpp
@@ -15,6 +15,10 @@ this program. If not, see .
#include "LoadPanel.h"
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
#include "text/Alignment.h"
#include "Color.h"
#include "Command.h"
@@ -547,6 +551,13 @@ void LoadPanel::WriteSnapshot(const filesystem::path &sourceFile, const filesyst
UpdateLists();
selectedFile = Files::Name(snapshotName);
loadedInfo.Load(Files::Saves() / selectedFile);
+#ifdef __EMSCRIPTEN__
+ EM_ASM(
+ FS.syncfs(false, function(err) {
+ if(err) console.error('IDBFS snapshot error:', err);
+ });
+ );
+#endif
}
else
GetUI().Push(DialogPanel::Info("Error: unable to create the file \"" + snapshotName.string() + "\"."));
@@ -587,6 +598,13 @@ void LoadPanel::DeletePilot(const string &)
sideHasFocus = true;
PilotProfile::DeleteProfile(selectedPilot, &GetUI());
+#ifdef __EMSCRIPTEN__
+ EM_ASM(
+ FS.syncfs(false, function(err) {
+ if(err) console.error('IDBFS delete error:', err);
+ });
+ );
+#endif
selectedPilot.reset();
selectedFile.clear();
UpdateLists();
@@ -602,6 +620,14 @@ void LoadPanel::DeleteSave()
Files::Delete(path);
if(Files::Exists(path))
GetUI().Push(DialogPanel::Info("Deleting snapshot file failed."));
+#ifdef __EMSCRIPTEN__
+ else
+ EM_ASM(
+ FS.syncfs(false, function(err) {
+ if(err) console.error('IDBFS delete error:', err);
+ });
+ );
+#endif
sideHasFocus = true;
selectedPilot.reset();
diff --git a/source/PlayerInfo.cpp b/source/PlayerInfo.cpp
index ff8932923..f09ad5a78 100644
--- a/source/PlayerInfo.cpp
+++ b/source/PlayerInfo.cpp
@@ -15,6 +15,10 @@ this program. If not, see .
#include "PlayerInfo.h"
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
#include "AI.h"
#include "audio/Audio.h"
#include "ConversationPanel.h"
@@ -682,6 +686,14 @@ void PlayerInfo::Save() const
// Save global conditions:
DataWriter globalConditions(Files::Config() / "global conditions.txt");
GameData::GlobalConditions().Save(globalConditions);
+
+#ifdef __EMSCRIPTEN__
+ EM_ASM(
+ FS.syncfs(false, function(err) {
+ if(err) console.error('IDBFS save error:', err);
+ });
+ );
+#endif
}
@@ -4945,6 +4957,14 @@ void PlayerInfo::Autosave() const
string path = filePath.substr(0, filePath.length() - 4) + "~autosave.txt";
Save(path);
+
+#ifdef __EMSCRIPTEN__
+ EM_ASM(
+ FS.syncfs(false, function(err) {
+ if(err) console.error('IDBFS autosave error:', err);
+ });
+ );
+#endif
}
diff --git a/source/TaskQueue.cpp b/source/TaskQueue.cpp
index 969569493..429fef0cb 100644
--- a/source/TaskQueue.cpp
+++ b/source/TaskQueue.cpp
@@ -22,6 +22,7 @@ this program. If not, see .
using namespace std;
+#ifndef __EMSCRIPTEN__
namespace {
// The main task queue used by the worker threads.
@@ -54,11 +55,13 @@ namespace {
vector threads;
} threads;
}
+#endif // !__EMSCRIPTEN__
void TaskQueue::SetWorkerThreadCount(uint64_t count)
{
+#ifndef __EMSCRIPTEN__
if(count == 0 || threads.threads.size() == count)
return;
@@ -66,6 +69,8 @@ void TaskQueue::SetWorkerThreadCount(uint64_t count)
lock_guard lock(asyncMutex);
shouldQuit = false;
new(&threads) WorkerThreads(count);
+#endif
+ // In Emscripten there is no worker thread pool to resize.
}
@@ -84,6 +89,25 @@ TaskQueue::~TaskQueue()
// any main thread task that still need to be executed!
shared_future TaskQueue::Run(function asyncTask, function syncTask)
{
+#ifdef __EMSCRIPTEN__
+ // In Emscripten, execute tasks synchronously on the calling thread.
+ if(asyncTask)
+ {
+ try {
+ asyncTask();
+ }
+ catch(...)
+ {
+ auto exception = current_exception();
+ syncTask = [exception] { rethrow_exception(exception); };
+ }
+ }
+ if(syncTask)
+ syncTasks.push(std::move(syncTask));
+ promise p;
+ p.set_value();
+ return p.get_future();
+#else
shared_future result;
{
lock_guard lock(asyncMutex);
@@ -98,6 +122,7 @@ shared_future TaskQueue::Run(function asyncTask, function
}
asyncCondition.notify_one();
return result;
+#endif
}
@@ -105,6 +130,14 @@ shared_future TaskQueue::Run(function asyncTask, function
// Process any tasks to be scheduled to be executed on the main thread.
void TaskQueue::ProcessSyncTasks()
{
+#ifdef __EMSCRIPTEN__
+ for(int i = 0; !syncTasks.empty() && i < MAX_SYNC_TASKS; ++i)
+ {
+ auto task = std::move(syncTasks.front());
+ syncTasks.pop();
+ task();
+ }
+#else
unique_lock lock(syncMutex);
for(int i = 0; !syncTasks.empty() && i < MAX_SYNC_TASKS; ++i)
{
@@ -116,6 +149,7 @@ void TaskQueue::ProcessSyncTasks()
task();
lock.lock();
}
+#endif
}
@@ -123,8 +157,11 @@ void TaskQueue::ProcessSyncTasks()
// Waits for all of this queue's task to finish. Ignores any sync tasks to be processed.
void TaskQueue::Wait()
{
+#ifndef __EMSCRIPTEN__
while(!IsDone())
this_thread::yield();
+#endif
+ // In Emscripten, all tasks execute synchronously in Run(), so nothing to wait for.
}
@@ -132,8 +169,12 @@ void TaskQueue::Wait()
// Whether there are any outstanding async tasks left in this queue.
bool TaskQueue::IsDone() const
{
+#ifdef __EMSCRIPTEN__
+ return true;
+#else
lock_guard lock(asyncMutex);
return futures.empty();
+#endif
}
@@ -141,6 +182,7 @@ bool TaskQueue::IsDone() const
// Thread entry point.
void TaskQueue::ThreadLoop() noexcept
{
+#ifndef __EMSCRIPTEN__
while(true)
{
unique_lock lock(asyncMutex);
@@ -193,4 +235,5 @@ void TaskQueue::ThreadLoop() noexcept
asyncCondition.wait(lock, [] { return shouldQuit || !tasks.empty(); });
}
+#endif // !__EMSCRIPTEN__
}
diff --git a/source/audio/Audio.cpp b/source/audio/Audio.cpp
index aa2df8b05..c27f9cc22 100644
--- a/source/audio/Audio.cpp
+++ b/source/audio/Audio.cpp
@@ -168,7 +168,13 @@ void Audio::LoadSounds(const vector &sources)
}
// Begin loading the files.
if(!loadQueue.empty())
+ {
+#ifdef __EMSCRIPTEN__
+ Load();
+#else
loadThread = thread(&Load);
+#endif
+ }
}
@@ -280,6 +286,10 @@ void Audio::Play(const Sound *sound, const Point &position, SoundCategory catego
// Play the given music. An empty string means to play nothing.
void Audio::PlayMusic(const string &name)
{
+#ifdef __EMSCRIPTEN__
+ // Music is disabled under Emscripten.
+ return;
+#endif
if(!isInitialized)
return;
@@ -431,12 +441,14 @@ void Audio::Quit()
unique_lock lock(audioMutex);
if(!loadQueue.empty())
loadQueue.clear();
+#ifndef __EMSCRIPTEN__
if(loadThread.joinable())
{
lock.unlock();
loadThread.join();
lock.lock();
}
+#endif
// Now, stop and delete any OpenAL sources that are playing.
players.clear();
diff --git a/source/audio/Music.cpp b/source/audio/Music.cpp
index efa824223..1d9bb5877 100644
--- a/source/audio/Music.cpp
+++ b/source/audio/Music.cpp
@@ -16,9 +16,11 @@ this program. If not, see .
#include "Music.h"
#include "../Files.h"
+#ifndef __EMSCRIPTEN__
#include "supplier/FlacSupplier.h"
-#include "../text/Format.h"
#include "supplier/Mp3Supplier.h"
+#endif
+#include "../text/Format.h"
#include