Endless Sky 0.11.3 for the web (fredygames.com), applied by build-endless-sky.sh (patch -p1). GPL-3.0-or-later, like the game. Everything is under __EMSCRIPTEN__; native builds are unchanged. Taken from the endless-web port by Thomas Ballinger (github.com/thomasballinger/endless-web, GPL-3.0, commits 0d8dea4324f6 "Emscripten build support" and 8678fa53da61 "Single-threaded mode and disable music for Emscripten", based on earlier work by Michal Janiszewski and Thomas Ballinger): - source/TaskQueue.cpp: no worker threads; tasks run at once on the main thread. - source/audio/Audio.cpp, source/audio/Music.cpp: sounds load on the main thread; no music (the MP3/FLAC decoders would need their own thread). - source/Files.cpp: no zip support (no minizip; plugins are not available in the browser). - source/GameWindow.cpp: no minimum-resolution warning; errors also go to stdout. Ours (fredygames.com): - source/image/ImageBuffer.cpp: read WebP images with libwebp (the build converts the PNG and JPG images to WebP to keep the download small); no libjpeg or libavif. - source/GameWindow.cpp: the window starts at the size of the page and never in full screen. - source/Screen.cpp: when the page's player is smaller than the UI needs at 100%, zoom out below 100% so that the menus fit. - source/FrameTimer.cpp: wait for the next frame with emscripten_sleep (returns to the browser). - source/PreferencesPanel.cpp, source/PlayerInfo.cpp: write the preferences when leaving the preferences panel and with every save, since browser tabs are usually closed, not quit. - source/Files.cpp: "open folder" does nothing (the folders live in IndexedDB). diff --git a/source/Files.cpp b/source/Files.cpp index 96f5e89..5aaeb4d 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 "SDL.h" @@ -48,6 +50,11 @@ namespace { // Open the given folder in a separate window. void OpenFolder(const filesystem::path &path) { +#ifdef __EMSCRIPTEN__ + // In the browser the folders live in the page's IndexedDB; there is nothing to open. + static_cast(path); + return; +#endif // TODO: Remove SDL version check after Ubuntu 20.04 reaches end of life #if SDL_VERSION_ATLEAST(2, 0, 14) if(SDL_OpenURL(("file://" + path.string()).c_str())) @@ -63,6 +70,16 @@ namespace { #endif } +#ifdef __EMSCRIPTEN__ + // No zip support in the web build (no minizip): plugins are not available there, and the game + // data is preloaded into the 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 +108,7 @@ namespace { return {}; } +#endif } diff --git a/source/FrameTimer.cpp b/source/FrameTimer.cpp index dcdf0e6..3c8c809 100644 --- a/source/FrameTimer.cpp +++ b/source/FrameTimer.cpp @@ -19,6 +19,8 @@ this program. If not, see . #define STRICT #define WIN32_LEAN_AND_MEAN #include +#elif defined(__EMSCRIPTEN__) +#include #else #include #endif @@ -70,6 +72,10 @@ void FrameTimer::Wait() // See the thread starting with https://sourceforge.net/p/mingw-w64/mailman/message/37013810/. #ifdef _WIN32 Sleep(chrono::duration_cast(next - now).count()); +#elif defined(__EMSCRIPTEN__) + // Give the rest of the frame back to the browser (with -sASYNCIFY this returns to the event + // loop and resumes here), instead of spinning until the next frame is due. + emscripten_sleep(chrono::duration_cast(next - now).count()); #else this_thread::sleep_until(next); #endif diff --git a/source/GameWindow.cpp b/source/GameWindow.cpp index 0e99043..257b76f 100644 --- a/source/GameWindow.cpp +++ b/source/GameWindow.cpp @@ -30,12 +30,18 @@ this program. If not, see . #include #include +#ifdef __EMSCRIPTEN__ +#include +#endif + 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; @@ -168,11 +174,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; @@ -191,10 +199,18 @@ bool GameWindow::Init(bool headless) // Settings that must be declared before the window creation. Uint32 flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI; +#ifdef __EMSCRIPTEN__ + // In the browser the window is the page's canvas, which the page stretches over the whole player + // (SDL keeps it at the size of the page when it is resized). Full screen needs a click or a key + // press, so the game always starts in the page, whatever the saved setting says. + windowWidth = max(1, EM_ASM_INT({ return window.innerWidth; })); + windowHeight = max(1, EM_ASM_INT({ return window.innerHeight; })); +#else if(Preferences::ScreenModeSetting() == "fullscreen") flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; else if(Preferences::Has("maximized")) flags |= SDL_WINDOW_MAXIMIZED; +#endif // The main window spawns visibly at this point. #ifdef ES_USE_SDL3 @@ -523,6 +539,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/PlayerInfo.cpp b/source/PlayerInfo.cpp index d6e66d0..ae85702 100644 --- a/source/PlayerInfo.cpp +++ b/source/PlayerInfo.cpp @@ -683,6 +683,11 @@ void PlayerInfo::Save() const // Save global conditions: DataWriter globalConditions(Files::Config() / "global conditions.txt"); GameData::GlobalConditions().Save(globalConditions); +#ifdef __EMSCRIPTEN__ + // In the browser the preferences (including which help messages were already shown) are written + // with every save, since the tab is usually closed without quitting the game. + Preferences::Save(); +#endif } diff --git a/source/PreferencesPanel.cpp b/source/PreferencesPanel.cpp index e19f248..ddc85ed 100644 --- a/source/PreferencesPanel.cpp +++ b/source/PreferencesPanel.cpp @@ -1380,6 +1380,11 @@ void PreferencesPanel::Exit() } Command::SaveSettings(Files::Config() / "keys.txt"); +#ifdef __EMSCRIPTEN__ + // A browser tab is usually closed rather than quit through the menu, so the preferences are + // written as soon as the player leaves this panel instead of only when the game quits. + Preferences::Save(); +#endif if(recacheDeadlines) player.CacheMissionInformation(true); diff --git a/source/Screen.cpp b/source/Screen.cpp index 4f1e632..b228d5b 100644 --- a/source/Screen.cpp +++ b/source/Screen.cpp @@ -96,6 +96,17 @@ void Screen::SetZoom(int percent, bool noEvent) int minZoomX = 100 * RAW_WIDTH / MIN_WIDTH; int minZoomY = 100 * RAW_HEIGHT / MIN_HEIGHT; int minZoom = min(minZoomX, minZoomY); +#ifdef __EMSCRIPTEN__ + // In the browser the window is the page's player, which can be smaller than the UI needs at 100% + // (a 4:3 frame of about 770 by 580 pixels): zoom out until the menus fit, instead of cutting them. + if(minZoom < 100) + { + EFFECTIVE_ZOOM = max(minZoom, 50); + WIDTH = RAW_WIDTH * 100 / EFFECTIVE_ZOOM; + HEIGHT = RAW_HEIGHT * 100 / EFFECTIVE_ZOOM; + return; + } +#endif // Never go below 100% zoom, no matter how small the window is minZoom = max(minZoom, 100); // Use increments of 10, like the user setting diff --git a/source/TaskQueue.cpp b/source/TaskQueue.cpp index 7f17774..6f10465 100644 --- a/source/TaskQueue.cpp +++ b/source/TaskQueue.cpp @@ -23,6 +23,7 @@ this program. If not, see . using namespace std; +#ifndef __EMSCRIPTEN__ namespace { // The main task queue used by the worker threads. @@ -55,11 +56,13 @@ namespace { vector threads; } threads; } +#endif // !__EMSCRIPTEN__ void TaskQueue::SetWorkerThreadCount(uint64_t count) { +#ifndef __EMSCRIPTEN__ if(count == 0 || threads.threads.size() == count) return; @@ -67,6 +70,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. } @@ -85,6 +90,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); @@ -99,6 +123,7 @@ shared_future TaskQueue::Run(function asyncTask, function } asyncCondition.notify_one(); return result; +#endif } @@ -106,6 +131,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) { @@ -117,6 +150,7 @@ void TaskQueue::ProcessSyncTasks() task(); lock.lock(); } +#endif } @@ -124,8 +158,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. } @@ -133,8 +170,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 } @@ -142,6 +183,7 @@ bool TaskQueue::IsDone() const // Thread entry point. void TaskQueue::ThreadLoop() noexcept { +#ifndef __EMSCRIPTEN__ while(true) { unique_lock lock(asyncMutex); @@ -194,4 +236,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 aa2df8b..c27f9cc 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 efa8242..1d9bb58 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 b4dfd4e..8ea3ee4 100644 --- a/source/image/ImageBuffer.cpp +++ b/source/image/ImageBuffer.cpp @@ -19,8 +19,14 @@ this program. If not, see . #include "ImageFileData.h" #include "../Logger.h" +#ifdef __EMSCRIPTEN__ +// The web build (fredygames.com) reads PNG and WebP only: its images are converted to WebP at build +// time, so neither libjpeg nor libavif is linked in. +#include +#else #include #include +#endif #include #include @@ -33,11 +39,19 @@ using namespace std; namespace { const set PNG_EXTENSIONS{".png"}; +#ifdef __EMSCRIPTEN__ + const set JPG_EXTENSIONS{}; + const set AVIF_EXTENSIONS{}; + const set WEBP_EXTENSIONS{".webp"}; +#else const set JPG_EXTENSIONS{".jpg", ".jpeg", ".jpe"}; const set AVIF_EXTENSIONS{".avif", ".avifs"}; + const set WEBP_EXTENSIONS{}; +#endif const set IMAGE_EXTENSIONS = []() { set extensions(PNG_EXTENSIONS); + extensions.insert(WEBP_EXTENSIONS.begin(), WEBP_EXTENSIONS.end()); extensions.insert(JPG_EXTENSIONS.begin(), JPG_EXTENSIONS.end()); extensions.insert(AVIF_EXTENSIONS.begin(), AVIF_EXTENSIONS.end()); return extensions; @@ -45,9 +59,13 @@ namespace { const set IMAGE_SEQUENCE_EXTENSIONS = AVIF_EXTENSIONS; bool ReadPNG(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions); +#ifdef __EMSCRIPTEN__ + bool ReadWEBP(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions); +#else bool ReadJPG(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions); int ReadAVIF(const filesystem::path &path, ImageBuffer &buffer, int frame, bool alphaPreMultiplied, bool onlyDimensions); +#endif void Premultiply(ImageBuffer &buffer, int frame, BlendingMode additive); } @@ -214,18 +232,31 @@ int ImageBuffer::Read(const ImageFileData &data, int frame, bool onlyDimensions) bool isPNG = PNG_EXTENSIONS.contains(data.extension); bool isJPG = JPG_EXTENSIONS.contains(data.extension); bool isAVIF = AVIF_EXTENSIONS.contains(data.extension); +#ifdef __EMSCRIPTEN__ + // WebP files are treated like the PNG they were made from (the JPG ones only use the additive + // blending mode, which PNG and JPG handle the same way). + if(WEBP_EXTENSIONS.contains(data.extension)) + isPNG = true; +#endif if(!isPNG && !isJPG && !isAVIF) return false; bool isAlphaPreMultiplied = data.blendingMode == BlendingMode::PREMULTIPLIED_ALPHA; int loaded; +#ifdef __EMSCRIPTEN__ + if(WEBP_EXTENSIONS.contains(data.extension)) + loaded = ReadWEBP(data.path, *this, frame, onlyDimensions); + else + loaded = ReadPNG(data.path, *this, frame, onlyDimensions); +#else if(isPNG) loaded = ReadPNG(data.path, *this, frame, onlyDimensions); else if(isJPG) loaded = ReadJPG(data.path, *this, frame, onlyDimensions); else loaded = ReadAVIF(data.path, *this, frame, isAlphaPreMultiplied, onlyDimensions); +#endif if(loaded <= 0) return 0; @@ -354,6 +385,44 @@ namespace { +#ifdef __EMSCRIPTEN__ + bool ReadWEBP(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions) + { + string data = Files::Read(path); + if(data.empty()) + return false; + const uint8_t *bytes = reinterpret_cast(data.data()); + + int width = 0; + int height = 0; + if(!WebPGetInfo(bytes, data.size(), &width, &height)) + return false; + // If the buffer is not yet allocated, allocate it. + try { + buffer.Allocate(width, height); + } + catch(const bad_alloc &) + { + const string message = "Failed to allocate contiguous memory for \"" + path.string() + "\""; + Logger::Log(message, Logger::Level::ERROR); + throw runtime_error(message); + } + // Make sure this frame's dimensions are valid. + if(!width || !height || width != buffer.Width() || height != buffer.Height()) + { + Logger::Log("Skipped processing \"" + path.string() + "\": all image frames must have equal size.", + Logger::Level::WARNING); + return false; + } + if(onlyDimensions) + return true; + + // The frame's rows are contiguous, four bytes per pixel in R, G, B, A order, as libpng writes them. + size_t stride = 4 * static_cast(width); + uint8_t *out = reinterpret_cast(buffer.Begin(0, frame)); + return WebPDecodeRGBAInto(bytes, data.size(), out, stride * height, stride) != nullptr; + } +#else bool ReadJPG(const filesystem::path &path, ImageBuffer &buffer, int frame, bool onlyDimensions) { string data = Files::Read(path); @@ -555,6 +624,7 @@ namespace { return bufferFrameCount; } +#endif // __EMSCRIPTEN__