Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy web version to Pages

on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false

jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
# Partially copy-pasted from ./build.yml
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: emscripten-core/emsdk
path: emsdk
- name: Install Dependencies
run: |
cd emsdk
./emsdk install 3.1.58
./emsdk activate 3.1.58
- name: Compile
env:
ARCHIVE: 1
ARCHIVE_NOZIP: 1
run: |
source emsdk/emsdk_env.sh
emmake make release -j$(nproc)
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: './build/release-emscripten-wasm32.zip'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,8 @@ ifeq ($(PLATFORM),emscripten)
endif

ifneq ($(BUILD_CLIENT),0)
TARGETS += $(B)/$(CLIENTBIN).html
TARGETS += $(B)/index.html
TARGETS += $(B)/upload-game-files-to-cache.html
ifneq ($(EMSCRIPTEN_PRELOAD_FILE),1)
TARGETS += $(B)/$(CLIENTBIN)-config.json
endif
Expand Down Expand Up @@ -1630,7 +1631,14 @@ endif
ifneq ($(PLATFORM),darwin)
ifdef ARCHIVE
@rm -f $@
ifndef ARCHIVE_NOZIP
@(cd $(B) && zip -r9 ../../$@ $(NAKED_TARGETS) $(NAKED_GENERATEDTARGETS))
else
# Instead of making a .zip file, make it a directory
# and simply copy the files there.
# This is a little ugly, but works for GitHub pages deployment.
@(cd $(B) && mkdir --parents ../../$@ && cp -t ../../$@ --parents $(NAKED_TARGETS) $(NAKED_GENERATEDTARGETS))
endif
endif
endif
@:
Expand Down Expand Up @@ -3091,10 +3099,14 @@ $(B)/$(MISSIONPACK)/qcommon/%.asm: $(CMDIR)/%.c $(Q3LCC)
# EMSCRIPTEN
#############################################################################

$(B)/$(CLIENTBIN).html: $(WEBDIR)/client.html
$(B)/index.html: $(WEBDIR)/index.html
$(echo_cmd) "SED $@"
$(Q)sed 's/__CLIENTBIN__/$(CLIENTBIN)/g;s/__BASEGAME__/$(BASEGAME)/g;s/__EMSCRIPTEN_PRELOAD_FILE__/$(EMSCRIPTEN_PRELOAD_FILE)/g' < $< > $@

$(B)/upload-game-files-to-cache.html: $(WEBDIR)/upload-game-files-to-cache.html
$(echo_cmd) "CP $@"
$(Q)cp $< $@

$(B)/$(CLIENTBIN)-config.json: $(WEBDIR)/client-config.json
$(echo_cmd) "CP $@"
$(Q)cp $< $@
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ For Web, building with Emscripten
`client-config.json` will be loaded.
4. Start a web server serving this directory. `python3 -m http.server`
is an easy default that you may already have installed.
5. Open `http://localhost:8000/build/debug-emscripten-wasm32/ioquake3.html`
5. Open `http://localhost:8000/build/debug-emscripten-wasm32/index.html`
in a web browser. Open the developer console to see errors and warnings.
6. Debugging the C code is possible using a Chrome extension. For details
see https://developer.chrome.com/blog/wasm-debugging-2020
Expand Down
48 changes: 40 additions & 8 deletions code/web/client.html → code/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
configFilename='./client-config.json';
}

if (window.location.protocol === 'file:') throw new Error(`Unfortunately browser security restrictions prevent loading wasm from a file: URL. This file must be loaded from a web server. The easiest way to do this is probably to use Python\'s built-in web server by running \`python3 -m http.server\` in the top level source directory and then navigate to http://localhost:8000/build/debug-emscripten-wasm32/${CLIENTBIN}.html`);
if (window.location.protocol === 'file:') throw new Error(`Unfortunately browser security restrictions prevent loading wasm from a file: URL. This file must be loaded from a web server. The easiest way to do this is probably to use Python\'s built-in web server by running \`python3 -m http.server\` in the top level source directory and then navigate to http://localhost:8000/build/debug-emscripten-wasm32/index.html`);

// First set up the command line arguments and the Emscripten filesystem.
const urlParams = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -73,6 +73,13 @@

const dataURL = new URL(dataPath, location.origin + location.pathname);

/**
* @typedef {{ src: string, dst: string }} FileConfig
*/
/**
* @typedef {Record<string, { files: Array<FileConfig> }>} Config
*/
/** @type {Promise<Config>} */
const configPromise = ( EMSCRIPTEN_PRELOAD_FILE === 1 ) ? Promise.resolve({[BASEGAME]: {files: []}})
: fetch(configFilename).then(r => r.ok ? r.json() : { /* empty config */ });

Expand All @@ -96,17 +103,42 @@
console.warn(`Game directory '${gamedir}' cannot be used. It must have files listed in ${configFilename}.`);
continue;
}
const cache = await caches.open('quake-game-files');
const files = config[gamedir].files;
const fetches = files.map(file => fetch(new URL(file.src, dataURL)));
for (let i = 0; i < files.length; i++) {
const response = await fetches[i];
if (!response.ok) continue;
/** @type {Array<Promise<[FileConfig, Response]>>} */
const fetches = files.map(async file => [
file,
(await cache.match(file.src)) ??
(await fetch(new URL(file.src, dataURL))),
]);

Promise.all(fetches).then(fetches => {
const missingFiles = fetches
.filter(([file, response]) => !response.ok)
.map(([file]) => file.src)
if (missingFiles.length !== 0) {
// Preserve query parameters,
// so that that page knows where to return to.
const nextPathname = new URL(
'./upload-game-files-to-cache.html',
location.href
).pathname;
const nextUrl = new URL(location.href);
nextUrl.pathname = nextPathname;
location.assign(nextUrl);
}
});

const writeFilePromises = fetches.map(async (p) => {
const [file, response] = await p;
if (!response.ok) return;
const data = await response.arrayBuffer();
let name = files[i].src.match(/[^/]+$/)[0];
let dir = files[i].dst;
let name = file.src.match(/[^/]+$/)[0];
let dir = file.dst;
module.FS.mkdirTree(dir);
module.FS.writeFile(`${dir}/${name}`, new Uint8Array(data));
}
});
await Promise.all(writeFilePromises);
}
} finally {
module.removeRunDependency('setup-ioq3-filesystem');
Expand Down
178 changes: 178 additions & 0 deletions code/web/upload-game-files-to-cache.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<title>ioquake3: upload game files</title>
</head>

<body>
<!-- TODO support uploading demo version, and Team Arena. -->

<p>
In order to play the game, you need to select the .pk3 files
from the original Quake III installation folder.
<!-- (either the demo version or the full game). -->
</p>
<!-- <p>
TODO point to a Windows demo version,
or just support extracting the `.pk3` from the Linux version,
as in https://github.com/jdarpinian/ioq3/blob/7801abd5ac510a889087ad62473c54dbe509e106/code/web/index.html#L621-L657.

The demo version for Linux can be downloaded from
<a
href="https://archive.org/download/tucows_286139_Quake_III_Arena/linuxq3ademo-1.11-6.x86.gz.zip/linuxq3ademo-1.11-6.x86.gz.sh"
download
>https://archive.org/download/tucows_286139_Quake_III_Arena/linuxq3ademo-1.11-6.x86.gz.zip/linuxq3ademo-1.11-6.x86.gz.sh</a>
<br>
</p> -->
<p>
The full version of Quake III Arena can be purchased
<a href="http://store.steampowered.com/app/2200/">on Steam</a>
or
<a href="https://www.gog.com/game/quake_iii_arena">on GOG</a>.
</p>
<p>
After downloading and installing the full game,
locate the game directory,
and upload the pak0.pk3 - pak8.pk3 files
from the "baseq3" directory,
using the file picker below.
</p>
<p>
To find the game directory on Steam,
find the game in your Steam library,
right click it, then, under the "Manage" submenu,
click "Browse local files".
</p>
<p>
For more info, see
<a href="https://ioquake3.org/help/players-guide">https://ioquake3.org/help/players-guide</a>.
</p>

<form id="gameFilesForm">
<label>
pak0.pk3 - pak8.pk3 files
<br>
<input id="gameFilesInput" type="file" accept=".pk3" multiple required />
</label>
<output>
<progress style="display: none" id="uploadProgress" min="0" max="33"></progress>
<p id="uploadError" style="display: none">Failed to upload</p>
</output>
</form>

<script type="module">
// @ts-check

/** @type {HTMLInputElement} */
const input = document.getElementById('gameFilesInput');
/** @type {HTMLFormElement} */
const form = document.getElementById('gameFilesForm');
input.onchange = async (event) => {
input.setCustomValidity('');

if (input.files == null || input.files.length === 0) {
return;
}

const expectedFileNames = [
'pak0.pk3',
'pak1.pk3',
'pak2.pk3',
'pak3.pk3',
'pak4.pk3',
'pak5.pk3',
'pak6.pk3',
'pak7.pk3',
'pak8.pk3',
]

let missingFiles = [...expectedFileNames];
// const extraneousFiles = [];
for (const actual of input.files) {
if (expectedFileNames.includes(actual.name)) {
missingFiles = missingFiles.filter(name => name !== actual.name)
} else {
// extraneousFiles.push(actual.name);
//
// Let's not prevent users from uploading extra `pk3` files.
}
}

/** @type {File | undefined} */
const pak0File = [...input.files].find(f => f.name === 'pak0.pk3')
if (pak0File != null && pak0File.size < 200_000_000) {
input.setCustomValidity(
`The size of the pak0.pk3 file is not the expected 457 MB. Are you sure this is a full game file and not a demo file?`
);
form.reportValidity();
return;
}

if (missingFiles.length > 0) {
input.setCustomValidity(
`Please select all files, including ${missingFiles.join(', ')}`
);
form.reportValidity();
return;
}

const cache = await caches.open('quake-game-files');

/** @type {File[]} */
const filesArr = [...input.files];
input.disabled = true;

/** @type {HTMLProgressElement} */
const progress = document.getElementById('uploadProgress');
progress.style.display = '';
const uploadStartedAt = Date.now()
const intervalId = setInterval(() => {
const secondsPassed = (Date.now() - uploadStartedAt) / 1000;
progress.value = secondsPassed;

// We expect to take this around 10-30 seconds.
// TODO better estimation somehow?
if (secondsPassed > 30) {
clearInterval(intervalId);
}
}, 100);

try {
await Promise.all(filesArr.map(async file => {
await cache.put(`baseq3/${file.name}`, new Response(file))
}));
} catch (error) {
/** @type {HTMLElement} */
const errorEl = document.getElementById('uploadError');
errorEl.style.display = '';
errorEl.innerText += ' ' + error;
progress.style.display = 'none';
return
} finally {
clearInterval(intervalId);
}

console.log('Full game uploaded!')
progress.value = progress.max;

// Modify path, but preseve query string.
// Note that `history.back();` might not work,
// because of the "back-forward cache":
// the previous page would simply remain in the previous state,
// i.e. the JavaScript will not re-execute.
const nextPathname = new URL(
'./index.html',
location.href
).pathname;
const nextUrl = new URL(location.href);
nextUrl.pathname = nextPathname
location.assign(nextUrl);
}
</script>
</body>

</html>
Loading