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 + + + +
+ + Please report this error on GitHub or the #third-party-chat room of the Endless Sky Discord. +
+
+
+ +
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

+ + +
+ +
+ +
Status goes here
+ +
+ +
+ + + + + Endless Web on GitHub + Endless Sky on GitHub + report bug + + +
+ + + +
+
+
+
+
+
+
esc
+
+
J
+
M
+
L
+
+
+
tab
+
W
+
Q
+
+
+ + + + + + + + + + + + + + + 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 @@ -37,6 +39,9 @@ namespace { void Music::Init(const vector &sources) { +#ifdef __EMSCRIPTEN__ + // Music is disabled under Emscripten. +#else for(const auto &source : sources) { // Find all the sound files that this resource source provides. @@ -53,12 +58,17 @@ void Music::Init(const vector &sources) paths[name] = {path, MusicFileType::FLAC}; } } +#endif } unique_ptr Music::CreateSupplier(const string &name, bool looping) { +#ifdef __EMSCRIPTEN__ + // Music is disabled under Emscripten. + return {}; +#else if(paths.contains(name)) switch(paths[name].second) { @@ -70,4 +80,5 @@ unique_ptr Music::CreateSupplier(const string &name, bool looping new FlacSupplier{Files::Open(paths[name].first), looping}}; } return {}; +#endif } diff --git a/source/image/ImageBuffer.cpp b/source/image/ImageBuffer.cpp index 5af05a94d..03f75f071 100644 --- a/source/image/ImageBuffer.cpp +++ b/source/image/ImageBuffer.cpp @@ -19,7 +19,9 @@ this program. If not, see . #include "ImageFileData.h" #include "../Logger.h" +#ifndef __EMSCRIPTEN__ #include +#endif #include #include @@ -34,7 +36,11 @@ using namespace std; namespace { const set PNG_EXTENSIONS{".png"}; const set JPG_EXTENSIONS{".jpg", ".jpeg", ".jpe"}; +#ifdef __EMSCRIPTEN__ + const set AVIF_EXTENSIONS{}; +#else const set AVIF_EXTENSIONS{".avif", ".avifs"}; +#endif const set IMAGE_EXTENSIONS = []() { set extensions(PNG_EXTENSIONS); @@ -46,8 +52,10 @@ namespace { bool ReadPNG(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions); bool ReadJPG(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions); +#ifndef __EMSCRIPTEN__ int ReadAVIF(const filesystem::path &path, ImageBuffer &buffer, int frame, bool alphaPreMultiplied, bool onlyDimensions); +#endif void Premultiply(ImageBuffer &buffer, int frame, BlendingMode additive); } @@ -204,7 +212,13 @@ int ImageBuffer::Read(const ImageFileData &data, int frame, bool onlyDimensions) else if(isJPG) loaded = ReadJPG(data.path, *this, frame, onlyDimensions); else + { +#ifndef __EMSCRIPTEN__ loaded = ReadAVIF(data.path, *this, frame, isAlphaPreMultiplied, onlyDimensions); +#else + return 0; +#endif + } if(loaded <= 0) return 0; @@ -402,6 +416,7 @@ namespace { +#ifndef __EMSCRIPTEN__ // Read an AVIF file, and return the number of frames. This might be // greater than the number of frames in the file due to frame time corrections. // Since sprite animation properties are not visible here, we take the shortest frame @@ -534,6 +549,7 @@ namespace { return bufferFrameCount; } +#endif // !__EMSCRIPTEN__ diff --git a/source/main.cpp b/source/main.cpp index 269f63ce0..6484c1a63 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -64,6 +64,10 @@ this program. If not, see . #include #include +#ifdef __EMSCRIPTEN__ +#include +#endif + #ifdef _WIN32 #define STRICT #define WIN32_LEAN_AND_MEAN @@ -157,6 +161,18 @@ int main(int argc, char *argv[]) if(nWorkerThreads) TaskQueue::SetWorkerThreadCount(nWorkerThreads); printData = PrintData::IsPrintDataArgument(argv); + +#ifdef __EMSCRIPTEN__ + EM_ASM( + if(!FS.analyzePath('/saves').exists) + FS.mkdir('/saves'); + FS.mount(IDBFS, {}, '/saves'); + FS.syncfs(true, function(err) { + if(err) console.error('IDBFS load error:', err); + }); + ); +#endif + Files::Init(argv); // Whether we are running an integration test.