diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e03941..6b37573 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -361,7 +361,46 @@ endif() add_subdirectory("third_party/fpattern") target_link_libraries(${EXECUTABLE_NAME} fpattern::fpattern) -if(FALLOUT_VENDORED) +if(EMSCRIPTEN) + # Web build (WebAssembly). Modified for browser use by termit.dev: + # SDL2 comes from the Emscripten port, zlib is the vendored copy, and + # game files are provided at runtime by the browser file layer in + # os/web/library_f2web.js (lazy, asynchronous reads via Asyncify). + add_subdirectory("third_party/zlib") + + target_sources(${EXECUTABLE_NAME} PUBLIC + "src/platform/web/web.cc" + "src/platform/web/web.h" + ) + + set(F2WEB_FLAGS "-sUSE_SDL=2") + # Keep build-machine paths out of __FILE__ strings in the shipped binary. + target_compile_options(${EXECUTABLE_NAME} PRIVATE ${F2WEB_FLAGS} + "-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/=") + target_link_options(${EXECUTABLE_NAME} PRIVATE + ${F2WEB_FLAGS} + "-sASYNCIFY=1" + "-sASYNCIFY_IMPORTS=['wasi_snapshot_preview1.fd_read']" + "-sASYNCIFY_STACK_SIZE=262144" + "-sSTACK_SIZE=4MB" + "-sINITIAL_MEMORY=128MB" + "-sALLOW_MEMORY_GROWTH=1" + "-sMAXIMUM_MEMORY=2GB" + "-sFORCE_FILESYSTEM=1" + "-sCASE_INSENSITIVE_FS=1" + "-sEXIT_RUNTIME=1" + "-sENVIRONMENT=web" + "-sMODULARIZE=1" + "-sEXPORT_NAME=createFallout2CE" + "-sEXPORTED_RUNTIME_METHODS=['FS','ENV']" + "--js-library=${CMAKE_CURRENT_SOURCE_DIR}/os/web/library_f2web.js" + "--extern-pre-js=${CMAKE_CURRENT_SOURCE_DIR}/os/web/banner.js" + ) + set_property(TARGET ${EXECUTABLE_NAME} APPEND PROPERTY LINK_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/os/web/library_f2web.js" + "${CMAKE_CURRENT_SOURCE_DIR}/os/web/banner.js") + set_target_properties(${EXECUTABLE_NAME} PROPERTIES SUFFIX ".js") +elseif(FALLOUT_VENDORED) add_subdirectory("third_party/zlib") add_subdirectory("third_party/sdl2") else() @@ -372,8 +411,10 @@ endif() target_link_libraries(${EXECUTABLE_NAME} ${ZLIB_LIBRARIES}) target_include_directories(${EXECUTABLE_NAME} PRIVATE ${ZLIB_INCLUDE_DIRS}) -target_link_libraries(${EXECUTABLE_NAME} ${SDL2_LIBRARIES}) -target_include_directories(${EXECUTABLE_NAME} PRIVATE ${SDL2_INCLUDE_DIRS}) +if(NOT EMSCRIPTEN) + target_link_libraries(${EXECUTABLE_NAME} ${SDL2_LIBRARIES}) + target_include_directories(${EXECUTABLE_NAME} PRIVATE ${SDL2_INCLUDE_DIRS}) +endif() if(APPLE) if(IOS) diff --git a/os/web/NOTICE.md b/os/web/NOTICE.md new file mode 100644 index 0000000..50df536 --- /dev/null +++ b/os/web/NOTICE.md @@ -0,0 +1,35 @@ +# Notice + +This folder contains a **modified version** of Fallout 2 Community Edition +(fallout2-ce) by Alexander Batalov and contributors, +. It is distributed free of charge +for non-commercial use under the Sustainable Use License; the full terms are in +[LICENSE.md](LICENSE.md). + +## What was modified + +These changes were made in October 2026 for the termit.dev web port, so the +engine runs in a web browser: + +- **Build:** compiled to WebAssembly with Emscripten 3.1.52 and SDL 2.24.2 + (`CMakeLists.txt`). +- **Browser file layer** (`os/web/library_f2web.js`): the visitor supplies the + game files. They are read lazily from browser storage, and the engine is + suspended with Asyncify while data loads. +- **Responsiveness:** the engine hands control back to the browser during long + input-polling loops (`src/platform/web/`, `src/input.cc`, `src/svga.cc`, + `src/delay.cc`, `src/fps_limiter.cc`). +- **Input:** the game cursor follows the browser pointer directly instead of + using relative mode or pointer lock (`src/dinput.cc`, `src/mouse.cc`). Touch + hints are set as on mobile builds (`src/win32.cc`). +- **Audio:** a larger audio buffer is used in browsers (`src/audio_engine.cc`). +- **Saves:** the page is told when saves and the config are written, so it can + store them in the browser (`src/loadsave.cc`, `src/game_config.cc`). +- **Loader page:** `index.html` is new. + +The complete source changes are in [web-port.patch](web-port.patch), made +against upstream commit `e97087b`. + +No game data is included. Fallout is a trademark of Bethesda Softworks LLC. +This project is not affiliated with or endorsed by Bethesda Softworks, +Interplay, or the fallout2-ce project. diff --git a/os/web/banner.js b/os/web/banner.js new file mode 100644 index 0000000..8b7286c --- /dev/null +++ b/os/web/banner.js @@ -0,0 +1,4 @@ +/*! F2CE web build: a MODIFIED version of fallout2-ce + * (https://github.com/alexbatalov/fallout2-ce), used under the Sustainable + * Use License (see LICENSE.md). Modified to run in web browsers for + * termit.dev; the changes are listed in NOTICE.md and web-port.patch. */ diff --git a/os/web/index.html b/os/web/index.html new file mode 100644 index 0000000..f82f3b6 --- /dev/null +++ b/os/web/index.html @@ -0,0 +1,1503 @@ + + + + + +F2CE — termit.dev + + + + + + + + + +
+
+ termit.dev/f2ce + + +
+ +
+

F2CE

+

The Fallout 2 Community Edition engine, compiled to WebAssembly. Bring your own copy of the game: it's copied into this browser's storage on your device, and nothing gets uploaded.

+
+ +
+
browserchecking…
+
game files—
+
saves—
+
+ + + + + + + + + +

Controls

+
+
Left click
walk, use, talk, pick up
+
Right click
switch cursor (move / look / use)
+
F6 / F7
quick save / quick load
+
Ctrl+S / Ctrl+L
save / load menu
+
Esc
close windows, open the game menu
+
Fullscreen
move the mouse to the top-right corner while playing
+
+ +

Good to know

+ + +
+

Engine: Fallout 2 Community Edition by Alexander Batalov and contributors, used under the Sustainable Use License. This copy is modified to run in web browsers (WebAssembly build, browser file layer, input changes): see NOTICE and the changes.

+

Not affiliated with or endorsed by Bethesda Softworks, Interplay, or the fallout2-ce project. Fallout is a trademark of Bethesda Softworks LLC. No game data is provided here.

+
+
+ + + + + + + diff --git a/os/web/library_f2web.js b/os/web/library_f2web.js new file mode 100644 index 0000000..a456ff4 --- /dev/null +++ b/os/web/library_f2web.js @@ -0,0 +1,320 @@ +// fallout2-ce web port (termit.dev): browser file layer. +// +// MODIFIED SOFTWARE NOTICE: this file is part of a modified version of +// fallout2-ce (https://github.com/alexbatalov/fallout2-ce), adapted to run in +// web browsers. It is not part of the upstream project. +// +// Game files chosen by the visitor are kept as Blobs (in IndexedDB, or the +// File objects straight from the folder picker). The engine sees them as +// ordinary read-only files. Reads are served from an in-memory chunk cache; +// on a cache miss the engine is suspended with Asyncify while the chunk is +// read from the Blob, so a 300+ MB master.dat never has to sit in memory. + +addToLibrary({ + $F2WEB__deps: ['$FS', '$MEMFS', '$Asyncify'], + // Maps cannot be written in the library object literal itself (it is + // serialized as source), so create them at startup. + $F2WEB__postset: 'F2WEB.cache = new Map(); F2WEB.pending = new Map(); Module["F2WEB"] = F2WEB;', + $F2WEB: { + CHUNK_SIZE: 1024 * 1024, + MAX_CACHE_BYTES: 128 * 1024 * 1024, + // ":" -> Uint8Array. Map keeps insertion order, which + // doubles as LRU order (entries are re-inserted on every hit). + cache: null, + cacheBytes: 0, + // ":" -> Promise of a chunk being read. + pending: null, + nextFileId: 1, + stats: { asyncLoads: 0, syncLoads: 0, bytesLoaded: 0, suspensions: 0 }, + + // Creates a lazily loaded file at `path` backed by `blob`. The parent + // directory must exist. Returns the FS node. + createBlobFile(path, blob) { + var parts = path.split('/'); + var name = parts.pop(); + var parent = parts.join('/') || '/'; + var node = FS.createFile(parent, name, {}, true, true); + node.f2blob = blob; + node.f2size = blob.size; + node.f2id = F2WEB.nextFileId++; + node.contents = null; + Object.defineProperty(node, 'usedBytes', { + configurable: true, + enumerable: true, + get() { + return this.f2size; + }, + set(value) { + // MEMFS is resizing the file, so the engine is rewriting it. Turn it + // into a plain in-memory file, keeping the data unless this is a + // truncate-to-zero. + var data = value !== 0 ? F2WEB.readWholeSync(this) : null; + F2WEB.detach(this, false); + if (data) { + var contents = new Uint8Array(Math.max(value, data.length)); + contents.set(data.subarray(0, Math.min(value, data.length))); + this.contents = contents; + } else { + this.contents = null; + } + this.usedBytes = value; + }, + }); + node.stream_ops = F2WEB.blobStreamOps; + return node; + }, + + isBlobNode(node) { + return !!(node && node.f2blob); + }, + + // Converts a blob-backed node into a regular MEMFS file. + detach(node, keepData) { + if (!node.f2blob) return; + var data = keepData ? F2WEB.readWholeSync(node) : null; + node.f2blob = null; + Object.defineProperty(node, 'usedBytes', { + configurable: true, + enumerable: true, + writable: true, + value: data ? data.length : 0, + }); + node.contents = data; + node.stream_ops = MEMFS.stream_ops; + }, + + readWholeSync(node) { + var out = new Uint8Array(node.f2size); + var chunks = Math.ceil(node.f2size / F2WEB.CHUNK_SIZE); + for (var i = 0; i < chunks; i++) { + var chunk = F2WEB.getCached(node, i) || F2WEB.loadChunkSync(node, i); + out.set(chunk, i * F2WEB.CHUNK_SIZE); + } + return out; + }, + + key(node, index) { + return node.f2id + ':' + index; + }, + + getCached(node, index) { + var key = F2WEB.key(node, index); + var chunk = F2WEB.cache.get(key); + if (chunk) { + F2WEB.cache.delete(key); + F2WEB.cache.set(key, chunk); + } + return chunk; + }, + + store(node, index, chunk) { + var key = F2WEB.key(node, index); + if (F2WEB.cache.has(key)) return; + F2WEB.cache.set(key, chunk); + F2WEB.cacheBytes += chunk.length; + while (F2WEB.cacheBytes > F2WEB.MAX_CACHE_BYTES && F2WEB.cache.size > 1) { + var oldest = F2WEB.cache.keys().next().value; + F2WEB.cacheBytes -= F2WEB.cache.get(oldest).length; + F2WEB.cache.delete(oldest); + } + }, + + range(node, index) { + var start = index * F2WEB.CHUNK_SIZE; + return [start, Math.min(node.f2size, start + F2WEB.CHUNK_SIZE)]; + }, + + loadChunk(node, index) { + var key = F2WEB.key(node, index); + var chunk = F2WEB.getCached(node, index); + if (chunk) return Promise.resolve(chunk); + var pending = F2WEB.pending.get(key); + if (pending) return pending; + var blob = node.f2blob; + var r = F2WEB.range(node, index); + var promise = blob.slice(r[0], r[1]).arrayBuffer().then((buffer) => { + var data = new Uint8Array(buffer); + F2WEB.pending.delete(key); + F2WEB.stats.asyncLoads++; + F2WEB.stats.bytesLoaded += data.length; + // The node may have been detached or unlinked meanwhile; the data is + // still valid for its old identity. + F2WEB.store(node, index, data); + return data; + }, (error) => { + F2WEB.pending.delete(key); + throw error; + }); + F2WEB.pending.set(key, promise); + return promise; + }, + + // Synchronous fallback, used only when the engine reads a missing chunk + // while it cannot be suspended (code called from a browser callback while + // the main game loop is already asleep). Synchronous XHR of a blob: URL + // works on the main thread in all current browsers. + loadChunkSync(node, index) { + var r = F2WEB.range(node, index); + var url = URL.createObjectURL(node.f2blob.slice(r[0], r[1])); + var data; + try { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.overrideMimeType('text/plain; charset=x-user-defined'); + xhr.send(null); + var text = xhr.responseText; + if (text.length !== r[1] - r[0]) { + throw new Error('short read from ' + url); + } + data = new Uint8Array(text.length); + for (var i = 0; i < text.length; i++) { + data[i] = text.charCodeAt(i) & 0xff; + } + } finally { + URL.revokeObjectURL(url); + } + F2WEB.stats.syncLoads++; + F2WEB.stats.bytesLoaded += data.length; + F2WEB.store(node, index, data); + return data; + }, + + // Chunk indexes needed for [position, position + length) that are not + // cached yet. + missing(node, position, length) { + if (!F2WEB.isBlobNode(node) || length <= 0 || position >= node.f2size) { + return null; + } + var end = Math.min(node.f2size, position + length); + var first = Math.floor(position / F2WEB.CHUNK_SIZE); + var last = Math.floor((end - 1) / F2WEB.CHUNK_SIZE); + var result = null; + for (var i = first; i <= last; i++) { + if (!F2WEB.cache.has(F2WEB.key(node, i))) { + (result || (result = [])).push(i); + } + } + return result; + }, + + loadMissing(node, indexes) { + var promises = indexes.map((index) => F2WEB.loadChunk(node, index)); + // Read-ahead: sequential readers (movies, music) usually want the next + // chunk soon after. + var next = indexes[indexes.length - 1] + 1; + if (next * F2WEB.CHUNK_SIZE < node.f2size && !F2WEB.cache.has(F2WEB.key(node, next))) { + F2WEB.loadChunk(node, next).catch(() => {}); + } + return Promise.all(promises); + }, + + // Total byte count requested by an iovec array. + iovLength(iov, iovcnt) { + var total = 0; + for (var i = 0; i < iovcnt; i++) { + total += HEAPU32[(iov + i * 8 + 4) >> 2]; + } + return total; + }, + + blobStreamOps: { + read(stream, buffer, offset, length, position) { + var node = stream.node; + if (!F2WEB.isBlobNode(node)) { + return MEMFS.stream_ops.read(stream, buffer, offset, length, position); + } + if (position >= node.f2size || length <= 0) return 0; + var end = Math.min(node.f2size, position + length); + var written = 0; + var pos = position; + while (pos < end) { + var index = Math.floor(pos / F2WEB.CHUNK_SIZE); + var chunk = F2WEB.getCached(node, index) || F2WEB.loadChunkSync(node, index); + var chunkStart = index * F2WEB.CHUNK_SIZE; + var from = pos - chunkStart; + var to = Math.min(chunk.length, end - chunkStart); + buffer.set(chunk.subarray(from, to), offset + written); + written += to - from; + pos += to - from; + } + return written; + }, + write(stream, buffer, offset, length, position, canOwn) { + F2WEB.detach(stream.node, true); + stream.stream_ops = MEMFS.stream_ops; + return MEMFS.stream_ops.write(stream, buffer, offset, length, position, false); + }, + llseek(stream, offset, whence) { + return MEMFS.stream_ops.llseek(stream, offset, whence); + }, + allocate(stream, offset, length) { + F2WEB.detach(stream.node, true); + stream.stream_ops = MEMFS.stream_ops; + return MEMFS.stream_ops.allocate(stream, offset, length); + }, + mmap(stream, length, position, prot, flags) { + F2WEB.detach(stream.node, true); + stream.stream_ops = MEMFS.stream_ops; + return MEMFS.stream_ops.mmap(stream, length, position, prot, flags); + }, + msync(stream, buffer, offset, length, mmapFlags) { + return 0; + }, + }, + }, + + // Asynchronous replacement for the WASI read call. When every byte the + // engine asks for is cached, this is an ordinary synchronous read. On a + // miss the engine is suspended until the chunk arrives from the Blob. + fd_read__deps: ['$F2WEB', '$doReadv', '$SYSCALLS', '$Asyncify', '$FS', '$callUserCallback'], + fd_read__async: true, + fd_read: function (fd, iov, iovcnt, pnum) { + if (Asyncify.state === Asyncify.State.Rewinding) { + // Resuming after an asynchronous chunk load: the read itself was already + // performed (see below); hand its result back to the engine. + return Asyncify.handleSleep(function () {}); + } + try { + var stream = SYSCALLS.getStreamFromFD(fd); + var node = stream.node; + var indexes = F2WEB.isBlobNode(node) + ? F2WEB.missing(node, stream.position, F2WEB.iovLength(iov, iovcnt)) + : null; + if (indexes && !Asyncify.currData) { + F2WEB.stats.suspensions++; + return Asyncify.handleSleep(function (wakeUp) { + F2WEB.loadMissing(node, indexes).then(function () { + var result; + try { + var current = SYSCALLS.getStreamFromFD(fd); + var num = doReadv(current, iov, iovcnt); + HEAPU32[pnum >> 2] = num; + result = 0; + } catch (e) { + if (!(e && e.name === 'ErrnoError')) { + console.error('f2web: read failed', e); + } + result = e && e.errno ? e.errno : 29 /* EIO */; + } + // callUserCallback handles the engine exiting (or crashing) while + // it resumes, like timer wake-ups do. + callUserCallback(function () { + wakeUp(result); + }); + }, function (error) { + console.error('f2web: could not read game file', error); + callUserCallback(function () { + wakeUp(29 /* EIO */); + }); + }); + }); + } + var num = doReadv(stream, iov, iovcnt); + HEAPU32[pnum >> 2] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + }, +}); diff --git a/src/audio_engine.cc b/src/audio_engine.cc index 6523c5c..b08b9fa 100644 --- a/src/audio_engine.cc +++ b/src/audio_engine.cc @@ -99,7 +99,13 @@ bool audioEngineInit() desiredSpec.freq = 22050; desiredSpec.format = AUDIO_S16; desiredSpec.channels = 2; +#ifdef __EMSCRIPTEN__ + // Web: the audio callback runs on the page's main thread between game + // frames, so give it more headroom. + desiredSpec.samples = 2048; +#else desiredSpec.samples = 1024; +#endif desiredSpec.callback = audioEngineMixin; gAudioEngineDeviceId = SDL_OpenAudioDevice(nullptr, 0, &desiredSpec, &gAudioEngineSpec, SDL_AUDIO_ALLOW_ANY_CHANGE); diff --git a/src/delay.cc b/src/delay.cc index dd9fa68..fb9ceac 100644 --- a/src/delay.cc +++ b/src/delay.cc @@ -2,10 +2,13 @@ #include +#include "platform/web/web.h" + void delay_ms(int ms) { if (ms <= 0) { return; } SDL_Delay(ms); + fallout::webNoteYield(); } diff --git a/src/dinput.cc b/src/dinput.cc index 171d529..0cf027a 100644 --- a/src/dinput.cc +++ b/src/dinput.cc @@ -1,10 +1,20 @@ #include "dinput.h" +#ifdef __EMSCRIPTEN__ +#include "mouse.h" +#include "svga.h" +#endif + namespace fallout { static int gMouseWheelDeltaX = 0; static int gMouseWheelDeltaY = 0; +#ifdef __EMSCRIPTEN__ +// Web: set once the browser reported a pointer position over the canvas. +static bool gWebMousePositionKnown = false; +#endif + // 0x4E0400 bool directInputInit() { @@ -53,7 +63,35 @@ bool mouseDeviceGetData(MouseData* mouseState) // update mouse position manually. SDL_PumpEvents(); +#ifdef __EMSCRIPTEN__ + // Web: no pointer lock. Move the game cursor to where the browser pointer + // is, so both stay aligned even after the pointer leaves the canvas. + int windowX; + int windowY; + Uint32 buttons = SDL_GetMouseState(&windowX, &windowY); + SDL_GetRelativeMouseState(nullptr, nullptr); + + mouseState->x = 0; + mouseState->y = 0; + + if (gWebMousePositionKnown && gSdlWindow != nullptr) { + int windowWidth; + int windowHeight; + SDL_GetWindowSize(gSdlWindow, &windowWidth, &windowHeight); + + int x = windowWidth > 0 ? windowX * screenGetWidth() / windowWidth : windowX; + int y = windowHeight > 0 ? windowY * screenGetHeight() / windowHeight : windowY; + + int cursorX; + int cursorY; + mouseGetPosition(&cursorX, &cursorY); + + mouseState->x = x - cursorX; + mouseState->y = y - cursorY; + } +#else Uint32 buttons = SDL_GetRelativeMouseState(&(mouseState->x), &(mouseState->y)); +#endif mouseState->buttons[0] = (buttons & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0; mouseState->buttons[1] = (buttons & SDL_BUTTON(SDL_BUTTON_RIGHT)) != 0; mouseState->wheelX = gMouseWheelDeltaX; @@ -93,7 +131,12 @@ bool keyboardDeviceGetData(KeyboardData* keyboardData) // 0x4E070C bool mouseDeviceInit() { +#ifdef __EMSCRIPTEN__ + // Web: relative mode would request pointer lock; see mouseDeviceGetData. + return true; +#else return SDL_SetRelativeMouseMode(SDL_TRUE) == 0; +#endif } // 0x4E078C @@ -117,6 +160,12 @@ void handleMouseEvent(SDL_Event* event) // Mouse movement and buttons are accumulated in SDL itself and will be // processed later in `mouseDeviceGetData` via `SDL_GetRelativeMouseState`. +#ifdef __EMSCRIPTEN__ + if (event->type == SDL_MOUSEMOTION || event->type == SDL_MOUSEBUTTONDOWN || event->type == SDL_MOUSEBUTTONUP) { + gWebMousePositionKnown = true; + } +#endif + if (event->type == SDL_MOUSEWHEEL) { gMouseWheelDeltaX += event->wheel.x; gMouseWheelDeltaY += event->wheel.y; diff --git a/src/fps_limiter.cc b/src/fps_limiter.cc index 7b62137..5e0fbd9 100644 --- a/src/fps_limiter.cc +++ b/src/fps_limiter.cc @@ -2,6 +2,8 @@ #include +#include "platform/web/web.h" + namespace fallout { FpsLimiter::FpsLimiter(unsigned int fps) @@ -19,6 +21,7 @@ void FpsLimiter::throttle() const { if (1000 / _fps > SDL_GetTicks() - _ticks) { SDL_Delay(1000 / _fps - (SDL_GetTicks() - _ticks)); + webNoteYield(); } } diff --git a/src/game_config.cc b/src/game_config.cc index f57e293..175a86c 100644 --- a/src/game_config.cc +++ b/src/game_config.cc @@ -1,4 +1,5 @@ #include "game_config.h" +#include "platform/web/web.h" #include "sfall_config.h" #include @@ -210,6 +211,8 @@ bool gameConfigSave() return false; } + webNotifyConfigSaved(); + return true; } diff --git a/src/input.cc b/src/input.cc index 33acd6e..69810f2 100644 --- a/src/input.cc +++ b/src/input.cc @@ -1,4 +1,5 @@ #include "input.h" +#include "platform/web/web.h" #include @@ -185,6 +186,10 @@ void _process_bk() { int v1; + // Web: keep the browser responsive in loops that poll input without + // presenting frames. + webMaybeYield(); + tickersExecute(); _mouse_info(); diff --git a/src/loadsave.cc b/src/loadsave.cc index f352c35..2575bb2 100644 --- a/src/loadsave.cc +++ b/src/loadsave.cc @@ -1,4 +1,5 @@ #include "loadsave.h" +#include "platform/web/web.h" #include #include @@ -1677,6 +1678,8 @@ static int lsgPerformSaveGame() snprintf(_gmpath, sizeof(_gmpath), "%s\\%s%.2d\\", "SAVEGAME", "SLOT", _slot_cursor + 1); MapDirErase(_gmpath, "BAK"); + webNotifyGameSaved(); + gLoadSaveMessageListItem.num = 140; if (messageListGetItem(&gLoadSaveMessageList, &gLoadSaveMessageListItem)) { displayMonitorAddMessage(gLoadSaveMessageListItem.text); diff --git a/src/mouse.cc b/src/mouse.cc index 70f9dc8..8251706 100644 --- a/src/mouse.cc +++ b/src/mouse.cc @@ -450,9 +450,11 @@ void _mouse_info() y = 0; } +#ifndef __EMSCRIPTEN__ // Adjust for mouse senstivity. x = (int)(x * gMouseSensitivity); y = (int)(y * gMouseSensitivity); +#endif _mouse_simulate_input(x, y, buttons); diff --git a/src/platform/web/web.cc b/src/platform/web/web.cc new file mode 100644 index 0000000..a646395 --- /dev/null +++ b/src/platform/web/web.cc @@ -0,0 +1,68 @@ +#include "web.h" + +#ifdef __EMSCRIPTEN__ +#include +#endif + +namespace fallout { + +#ifdef __EMSCRIPTEN__ + +// Longest stretch (ms) the engine may run before the browser gets a turn. +static constexpr double kWebMaxBusyMs = 25.0; + +static double gWebLastYield = 0.0; + +void webNoteYield() +{ + gWebLastYield = emscripten_get_now(); +} + +void webMaybeYield() +{ + double now = emscripten_get_now(); + if (now - gWebLastYield >= kWebMaxBusyMs) { + emscripten_sleep(0); + gWebLastYield = emscripten_get_now(); + } +} + +void webNotifyGameSaved() +{ + // clang-format off + EM_ASM({ + if (Module["onGameSaved"]) Module["onGameSaved"](); + }); + // clang-format on +} + +void webNotifyConfigSaved() +{ + // clang-format off + EM_ASM({ + if (Module["onConfigSaved"]) Module["onConfigSaved"](); + }); + // clang-format on +} + +#else + +void webNoteYield() +{ +} + +void webMaybeYield() +{ +} + +void webNotifyGameSaved() +{ +} + +void webNotifyConfigSaved() +{ +} + +#endif + +} // namespace fallout diff --git a/src/platform/web/web.h b/src/platform/web/web.h new file mode 100644 index 0000000..034ad86 --- /dev/null +++ b/src/platform/web/web.h @@ -0,0 +1,25 @@ +#ifndef FALLOUT_PLATFORM_WEB_WEB_H_ +#define FALLOUT_PLATFORM_WEB_WEB_H_ + +// Browser (Emscripten) build helpers. Added for the termit.dev web port; +// not part of upstream fallout2-ce. All of these are no-ops elsewhere. + +namespace fallout { + +// Records that the engine just handed control back to the browser +// (frame presented or SDL_Delay slept). +void webNoteYield(); + +// Gives the browser a turn when the engine has been busy for too long +// without presenting a frame, so input, audio and painting keep flowing +// even inside loops that only poll input. +void webMaybeYield(); + +// Tells the page that save files or the game config were written, so it can +// copy them into persistent browser storage right away. +void webNotifyGameSaved(); +void webNotifyConfigSaved(); + +} // namespace fallout + +#endif /* FALLOUT_PLATFORM_WEB_WEB_H_ */ diff --git a/src/svga.cc b/src/svga.cc index 8acca96..781f8c8 100644 --- a/src/svga.cc +++ b/src/svga.cc @@ -1,4 +1,5 @@ #include "svga.h" +#include "platform/web/web.h" #include #include @@ -175,7 +176,13 @@ int _GNW95_init_window(int width, int height, bool fullscreen, int scale) if (gSdlWindow == nullptr) { SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl"); +#ifdef __EMSCRIPTEN__ + // Web: render at the game resolution and let the page scale the + // canvas with CSS. + Uint32 windowFlags = SDL_WINDOW_OPENGL; +#else Uint32 windowFlags = SDL_WINDOW_OPENGL | SDL_WINDOW_ALLOW_HIGHDPI; +#endif if (fullscreen) { windowFlags |= SDL_WINDOW_FULLSCREEN; @@ -413,6 +420,7 @@ void renderPresent() SDL_RenderClear(gSdlRenderer); SDL_RenderCopy(gSdlRenderer, gSdlTexture, nullptr, nullptr); SDL_RenderPresent(gSdlRenderer); + webNoteYield(); } } // namespace fallout diff --git a/src/win32.cc b/src/win32.cc index 56e8881..297b0a2 100644 --- a/src/win32.cc +++ b/src/win32.cc @@ -48,6 +48,11 @@ int main(int argc, char* argv[]) SDL_free(basePath); #endif +#ifdef __EMSCRIPTEN__ + SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"); + SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); +#endif + #if __ANDROID__ SDL_SetHint(SDL_HINT_MOUSE_TOUCH_EVENTS, "0"); SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0");