From 489c4a36616eb2df889f1d9b54fa97d471a40103 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Mon, 16 Feb 2026 17:41:43 +0100 Subject: [PATCH] Add packaging game files and bundling them into final Web app --- .../Platform/Android/AndroidPlatformTools.cpp | 2 +- .../Cooker/Platform/Web/WebPlatformTools.cpp | 99 ++++++++++++++++++- .../Cooker/Platform/Web/WebPlatformTools.h | 1 + .../Engine/Platform/Base/FileSystemBase.cpp | 4 +- Source/Engine/Platform/Base/FileSystemBase.h | 4 +- .../Flax.Build/Platforms/Web/WebToolchain.cs | 4 + 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/Source/Editor/Cooker/Platform/Android/AndroidPlatformTools.cpp b/Source/Editor/Cooker/Platform/Android/AndroidPlatformTools.cpp index 9e4f18b2c..1b6e7a616 100644 --- a/Source/Editor/Cooker/Platform/Android/AndroidPlatformTools.cpp +++ b/Source/Editor/Cooker/Platform/Android/AndroidPlatformTools.cpp @@ -138,6 +138,7 @@ Array AndroidPlatformTools::SaveCache(CookingData& data, IBuildCache* cach result.Add((const byte*)&platformCache, sizeof(platformCache)); return result; } + void AndroidPlatformTools::OnBuildStarted(CookingData& data) { // Adjust the cooking output folder to be located inside the Gradle assets directory @@ -411,7 +412,6 @@ bool AndroidPlatformTools::OnPostProcess(CookingData& data) return true; } LOG(Info, "Output Android APK application package: {0} (size: {1} MB)", outputApk, FileSystem::GetFileSize(outputApk) / 1024 / 1024); - return false; } diff --git a/Source/Editor/Cooker/Platform/Web/WebPlatformTools.cpp b/Source/Editor/Cooker/Platform/Web/WebPlatformTools.cpp index ab7a3aaa0..f735130bf 100644 --- a/Source/Editor/Cooker/Platform/Web/WebPlatformTools.cpp +++ b/Source/Editor/Cooker/Platform/Web/WebPlatformTools.cpp @@ -5,12 +5,14 @@ #include "WebPlatformTools.h" #include "Engine/Platform/File.h" #include "Engine/Platform/FileSystem.h" +#include "Engine/Platform/CreateProcessSettings.h" #include "Engine/Platform/Web/WebPlatformSettings.h" #include "Engine/Core/Config/GameSettings.h" #include "Engine/Core/Config/BuildSettings.h" #include "Engine/Content/Content.h" #include "Engine/Content/JsonAsset.h" #include "Engine/Graphics/PixelFormatExtensions.h" +#include "Editor/Cooker/GameCooker.h" IMPLEMENT_SETTINGS_GETTER(WebPlatformSettings, WebPlatform); @@ -48,12 +50,107 @@ PixelFormat WebPlatformTools::GetTextureFormat(CookingData& data, TextureBase* t bool WebPlatformTools::IsNativeCodeFile(CookingData& data, const String& file) { String extension = FileSystem::GetExtension(file); - return extension.IsEmpty() || extension == TEXT("html") || extension == TEXT("js") || extension == TEXT("wams"); + return extension.IsEmpty() || extension == TEXT("html") || extension == TEXT("js") || extension == TEXT("wasm"); +} + +void WebPlatformTools::OnBuildStarted(CookingData& data) +{ + // Adjust the cooking output folder for the data files so file_packager tool can build the and output final data inside the cooker output folder + data.DataOutputPath = data.CacheDirectory / TEXT("Files"); } bool WebPlatformTools::OnPostProcess(CookingData& data) { + const auto gameSettings = GameSettings::Get(); + const auto platformSettings = WebPlatformSettings::Get(); + const auto platformDataPath = data.GetPlatformBinariesRoot(); + + // Get name of the output binary (JavaScript and WebAssembly files match) + String gameJs; + { + Array files; + FileSystem::DirectoryGetFiles(files, data.OriginalOutputPath, TEXT("*"), DirectorySearchOption::TopDirectoryOnly); + for (String& file : files) + { + if (file.EndsWith(TEXT(".js"))) + { + String outputWasm = String(StringUtils::GetPathWithoutExtension(file)) + TEXT(".wasm"); + if (files.Contains(outputWasm)) + { + gameJs = file; + break; + } + } + } + } + if (gameJs.IsEmpty()) + { + data.Error(TEXT("Failed to find the main JavaScript for the output game")); + return true; + } + + // Pack data files into a single file using Emscripten's file_packager tool + { + CreateProcessSettings procSettings; + String emscriptenSdk = TEXT("EMSDK"); + Platform::GetEnvironmentVariable(emscriptenSdk, emscriptenSdk); + procSettings.FileName = emscriptenSdk / TEXT("upstream/emscripten/tools/file_packager"); +#if PLATFORM_WIN32 + procSettings.FileName += TEXT(".bat"); +#endif + procSettings.Arguments = String::Format(TEXT("files.data --preload \"{}@/\" --lz4 --js-output=files.js"), data.DataOutputPath); + procSettings.WorkingDirectory = data.OriginalOutputPath; + const int32 result = Platform::CreateProcess(procSettings); + if (result != 0) + { + if (!FileSystem::FileExists(procSettings.FileName)) + data.Error(TEXT("Missing file_packager.bat. Ensure Emscripten SDK installation is valid and 'EMSDK' environment variable points to it.")); + data.Error(String::Format(TEXT("Failed to package project files (result code: {0}). See log for more info."), result)); + return true; + } + } + // TODO: customizable HTML templates + + // Insert packaged file system with game data + { + String gameJsText; + if (File::ReadAllText(gameJs, gameJsText)) + { + data.Error(String::Format(TEXT("Failed to load file '{}'"), gameJs)); + return true; + } + const String filesIncludeBegin = TEXT("// include: files.js"); + const String filesIncludeEnd = TEXT("// end include: files.js"); + if (!gameJsText.Contains(filesIncludeBegin)) + { + // Insert generated files.js into the main game file after the minimum_runtime_check.js include + String fileJsText; + String fileJs = data.OriginalOutputPath / TEXT("files.js"); + if (File::ReadAllText(fileJs, fileJsText)) + { + data.Error(String::Format(TEXT("Failed to load file '{}'"), fileJs)); + return true; + } + const String insertPrefixLocation = TEXT("// end include: minimum_runtime_check.js"); + int32 location = gameJsText.Find(insertPrefixLocation); + CHECK_RETURN(location != -1, true); + location += insertPrefixLocation.Length() + 1; + fileJsText = filesIncludeBegin + TEXT("\n") + fileJsText + TEXT("\n") + filesIncludeEnd + TEXT("\n"); + gameJsText.Insert(location, fileJsText); + File::WriteAllText(gameJs, gameJsText, Encoding::UTF8); + } + } + + const auto buildSettings = BuildSettings::Get(); + if (buildSettings->SkipPackaging) + return false; + GameCooker::PackageFiles(); + + // TODO: minify/compress output JS files (in Release builds) + + LOG(Info, "Output website size: {0} MB", FileSystem::GetDirectorySize(data.OriginalOutputPath) / 1024 / 1024); + return false; } diff --git a/Source/Editor/Cooker/Platform/Web/WebPlatformTools.h b/Source/Editor/Cooker/Platform/Web/WebPlatformTools.h index 7b14dcd10..abddb6126 100644 --- a/Source/Editor/Cooker/Platform/Web/WebPlatformTools.h +++ b/Source/Editor/Cooker/Platform/Web/WebPlatformTools.h @@ -20,6 +20,7 @@ public: DotNetAOTModes UseAOT() const override; PixelFormat GetTextureFormat(CookingData& data, TextureBase* texture, PixelFormat format) override; bool IsNativeCodeFile(CookingData& data, const String& file) override; + void OnBuildStarted(CookingData& data) override; bool OnPostProcess(CookingData& data) override; }; diff --git a/Source/Engine/Platform/Base/FileSystemBase.cpp b/Source/Engine/Platform/Base/FileSystemBase.cpp index 13ae3481c..496897414 100644 --- a/Source/Engine/Platform/Base/FileSystemBase.cpp +++ b/Source/Engine/Platform/Base/FileSystemBase.cpp @@ -244,11 +244,11 @@ bool FileSystemBase::CopyDirectory(const String& dst, const String& src, bool wi return !FileSystem::DirectoryExists(*src) || FileSystemBase::DirectoryCopyHelper(dst, src, withSubDirectories); } -uint64 FileSystemBase::GetDirectorySize(const StringView& path) +uint64 FileSystemBase::GetDirectorySize(const StringView& path, const Char* searchPattern, DirectorySearchOption option) { uint64 result = 0; Array files; - FileSystem::DirectoryGetFiles(files, path); + FileSystem::DirectoryGetFiles(files, path, searchPattern, option); for (const String& file : files) result += FileSystem::GetFileSize(file); return result; diff --git a/Source/Engine/Platform/Base/FileSystemBase.h b/Source/Engine/Platform/Base/FileSystemBase.h index 1a4980d95..f95134791 100644 --- a/Source/Engine/Platform/Base/FileSystemBase.h +++ b/Source/Engine/Platform/Base/FileSystemBase.h @@ -98,8 +98,10 @@ class FLAXENGINE_API FileSystemBase /// Gets the size of the directory (in bytes) defined by size of all files contained by it. /// /// Directory path. + /// Custom search pattern to use during that operation Use asterisk character (*) for name-based filtering (eg. `*.txt` to find all files with `.txt` extension). + /// Additional search options that define rules. /// Amount of bytes in directory, or 0 if failed. - static uint64 GetDirectorySize(const StringView& path); + static uint64 GetDirectorySize(const StringView& path, const Char* searchPattern = TEXT("*"), DirectorySearchOption option = DirectorySearchOption::AllDirectories); public: /// diff --git a/Source/Tools/Flax.Build/Platforms/Web/WebToolchain.cs b/Source/Tools/Flax.Build/Platforms/Web/WebToolchain.cs index 95fef1929..70551db4d 100644 --- a/Source/Tools/Flax.Build/Platforms/Web/WebToolchain.cs +++ b/Source/Tools/Flax.Build/Platforms/Web/WebToolchain.cs @@ -251,6 +251,10 @@ namespace Flax.Build.Platforms args.Add($"-sINITIAL_MEMORY={initialMemory}MB"); args.Add("-sSTACK_SIZE=4MB"); args.Add("-sALLOW_MEMORY_GROWTH=1"); + + // Setup file access (Game Cooker packs files with file_packager tool) + args.Add("-sFORCE_FILESYSTEM"); + args.Add("-sLZ4"); } args.Add("-Wl,--start-group");