diff --git a/.gitignore b/.gitignore index 1bd0073..4e126bf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ CMake* !CMakeLists.txt CPack* Makefile +build.ninja source/manual/ -source/manual/block_attack_manual.tex man/blockattack.6.gz @@ -29,3 +30,4 @@ build _CPack_Packages #Different CPack results.. blockattack-* +build-web diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d23602..7c5366b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,28 +45,81 @@ if (${CMAKE_SYSTEM_NAME} MATCHES "Darwin") set(MACOSX TRUE) endif() -# Setup SDL2 and extensions -find_package(SDL2 REQUIRED) - -# Try modern CMake approach first, fallback to pkg-config for cross-compilation (e.g., MXE) -find_package(SDL2_image QUIET) -find_package(SDL2_mixer QUIET) -find_package(SDL2_ttf QUIET) - -set(SDL2_USE_PKGCONFIG OFF) -if(NOT SDL2_image_FOUND OR NOT SDL2_mixer_FOUND OR NOT SDL2_ttf_FOUND) - set(SDL2_USE_PKGCONFIG ON) - find_package(PkgConfig REQUIRED) - # When using pkg-config, always search for all SDL2 extensions - pkg_search_module(SDL2_image REQUIRED SDL2_image) - pkg_search_module(SDL2_mixer REQUIRED SDL2_mixer) - pkg_search_module(SDL2_ttf REQUIRED SDL2_ttf) -endif() +# Platform dependencies (SDL2 family, PhysFS, gettext, Boost) are carried by +# this INTERFACE target, however each platform provides them. +add_library(blockattack_deps INTERFACE) + +if(EMSCRIPTEN) + # Data and locale are preloaded at "/" and cwd is "/", so the STANDALONE + # layout (SHAREDIR=".", LOCALEDIR="./locale") is exactly right. + set(STANDALONE ON) + + # SDL2 family from Emscripten ports. Same flags at compile time (headers) + # and link time (prebuilt libraries). + set(BLOCKATTACK_EM_PORTS -sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sSDL2_IMAGE_FORMATS=png,jpg + -sUSE_SDL_TTF=2 -sUSE_SDL_MIXER=2 -sSDL2_MIXER_FORMATS=ogg) + target_compile_options(blockattack_deps INTERFACE ${BLOCKATTACK_EM_PORTS}) + target_link_options(blockattack_deps INTERFACE ${BLOCKATTACK_EM_PORTS}) + + # PhysFS has no port; build it from source (zip archiver only, packdata.sh + # produces zips). Offline: -DFETCHCONTENT_SOURCE_DIR_PHYSFS= + include(FetchContent) + FetchContent_Declare(physfs + GIT_REPOSITORY https://github.com/icculus/physfs.git + GIT_TAG release-3.2.0) + set(PHYSFS_BUILD_SHARED OFF CACHE BOOL "" FORCE) + set(PHYSFS_BUILD_TEST OFF CACHE BOOL "" FORCE) + set(PHYSFS_BUILD_DOCS OFF CACHE BOOL "" FORCE) + foreach(archiver 7Z GRP WAD HOG MVL QPAK SLB ISO9660 VDF) + set(PHYSFS_ARCHIVE_${archiver} OFF CACHE BOOL "" FORCE) + endforeach() + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) # physfs 3.2.0 still declares CMake 3.0 + FetchContent_MakeAvailable(physfs) + unset(CMAKE_POLICY_VERSION_MINIMUM) + target_link_libraries(blockattack_deps INTERFACE PhysFS::PhysFS-static) + + # libintl is part of Emscripten's libc. Boost.program_options is not used + # in the browser (see main.cpp). Exceptions are the game's error path and + # emcc disables catching them unless asked; Asyncify needs the JS-based flavour. + add_compile_options(-fexceptions) + add_link_options(-fexceptions) +else() + # Setup SDL2 and extensions + find_package(SDL2 REQUIRED) + + # Try modern CMake approach first, fallback to pkg-config for cross-compilation (e.g., MXE) + find_package(SDL2_image QUIET) + find_package(SDL2_mixer QUIET) + find_package(SDL2_ttf QUIET) + + set(SDL2_USE_PKGCONFIG OFF) + if(NOT SDL2_image_FOUND OR NOT SDL2_mixer_FOUND OR NOT SDL2_ttf_FOUND) + set(SDL2_USE_PKGCONFIG ON) + find_package(PkgConfig REQUIRED) + # When using pkg-config, always search for all SDL2 extensions + pkg_search_module(SDL2_image REQUIRED SDL2_image) + pkg_search_module(SDL2_mixer REQUIRED SDL2_mixer) + pkg_search_module(SDL2_ttf REQUIRED SDL2_ttf) + endif() + find_package(Intl REQUIRED) -find_package(Intl REQUIRED) + find_package(Boost COMPONENTS program_options REQUIRED NO_MODULE) -find_package(Boost COMPONENTS program_options REQUIRED NO_MODULE) + # Link SDL2 extensions - use imported targets if available, otherwise use pkg-config variables + if(SDL2_USE_PKGCONFIG) + target_include_directories(blockattack_deps INTERFACE ${SDL2_image_INCLUDE_DIRS} ${SDL2_mixer_INCLUDE_DIRS} ${SDL2_ttf_INCLUDE_DIRS}) + target_link_directories(blockattack_deps INTERFACE ${SDL2_image_LIBRARY_DIRS} ${SDL2_mixer_LIBRARY_DIRS} ${SDL2_ttf_LIBRARY_DIRS}) + target_link_libraries(blockattack_deps INTERFACE ${SDL2_image_LINK_LIBRARIES} ${SDL2_mixer_LINK_LIBRARIES} ${SDL2_ttf_LINK_LIBRARIES}) + else() + target_link_libraries(blockattack_deps INTERFACE SDL2_image::SDL2_image SDL2_mixer::SDL2_mixer SDL2_ttf::SDL2_ttf) + endif() + target_include_directories(blockattack_deps INTERFACE ${Intl_INCLUDE_DIRS}) + target_link_libraries(blockattack_deps INTERFACE SDL2::SDL2 physfs ${Boost_LIBRARIES}) + if (MACOSX) + target_link_libraries(blockattack_deps INTERFACE intl) + endif() +endif() #Find all sources but one file(GLOB SOURCES CONFIGURE_DEPENDS "source/code/*.cpp" "source/code/*.inc" "source/code/*.h*" "source/code/*/*.cpp" "source/code/*/*.h*" "source/code/Libs/*.c" "source/code/Libs/*.h*" "source/code/Libs/*/*.cpp" "source/code/Libs/*/*/*.cpp") @@ -124,36 +177,47 @@ endif() target_include_directories(blockattack SYSTEM PRIVATE "source/code/Libs/imgui") target_include_directories(blockattack PRIVATE "source/code/Libs/include") -if(SDL2_USE_PKGCONFIG) - target_include_directories(blockattack PRIVATE ${SDL2_image_INCLUDE_DIRS}) - target_include_directories(blockattack PRIVATE ${SDL2_mixer_INCLUDE_DIRS}) - target_include_directories(blockattack PRIVATE ${SDL2_ttf_INCLUDE_DIRS}) - # Add library directories for pkg-config-based libraries - target_link_directories(blockattack PRIVATE ${SDL2_image_LIBRARY_DIRS}) - target_link_directories(blockattack PRIVATE ${SDL2_mixer_LIBRARY_DIRS}) - target_link_directories(blockattack PRIVATE ${SDL2_ttf_LIBRARY_DIRS}) -endif() -target_include_directories(blockattack PRIVATE ${Intl_INCLUDE_DIRS}) target_link_libraries(blockattack PRIVATE - SDL2::SDL2 + blockattack_deps fmt platform_folders - physfs - ${Boost_LIBRARIES} ) +set_target_properties(blockattack PROPERTIES RUNTIME_OUTPUT_DIRECTORY Game) -# Link SDL2 extensions - use imported targets if available, otherwise use pkg-config variables -if(SDL2_USE_PKGCONFIG) - target_link_libraries(blockattack PRIVATE ${SDL2_image_LINK_LIBRARIES} ${SDL2_mixer_LINK_LIBRARIES} ${SDL2_ttf_LINK_LIBRARIES}) -else() - target_link_libraries(blockattack PRIVATE SDL2_image::SDL2_image SDL2_mixer::SDL2_mixer SDL2_ttf::SDL2_ttf) -endif() - -if (MACOSX) - target_link_libraries(blockattack PRIVATE intl) +if(EMSCRIPTEN) + set(BLOCKATTACK_WEB_SHELL "${blockattack_SOURCE_DIR}/source/misc/web/shell.html") + set(BLOCKATTACK_DATA_FILE "${blockattack_SOURCE_DIR}/Game/blockattack.data") + set(BLOCKATTACK_MOD_FILE "${blockattack_SOURCE_DIR}/Game/mods/1.3.0.bricks.data") + set(BLOCKATTACK_LOCALE_DIR "${blockattack_SOURCE_DIR}/source/misc/translation/locale") + foreach(f IN ITEMS "${BLOCKATTACK_DATA_FILE}" "${BLOCKATTACK_MOD_FILE}" "${BLOCKATTACK_LOCALE_DIR}") + if(NOT EXISTS "${f}") + message(FATAL_ERROR "${f} is missing. Run ./packdata.sh before configuring the web build.") + endif() + endforeach() + # One page: index.html (from the shell, which sizes the canvas to the + # browser window) loads index.js, index.wasm and index.data. ASYNCIFY lets + # the blocking SDL_Delay game loops yield to the browser each frame instead + # of being rewritten around a callback; its stack holds the locals of every + # unwound frame, so it is sized well above the default 4 KiB. IDBFS backs + # the save folder (see os.cpp). + set_target_properties(blockattack PROPERTIES + OUTPUT_NAME index + SUFFIX ".html" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/web") + target_link_options(blockattack PRIVATE + -sASYNCIFY + -sASYNCIFY_STACK_SIZE=1048576 + -sALLOW_MEMORY_GROWTH=1 + -lidbfs.js + $<$:-sASSERTIONS=1> + "SHELL:--shell-file ${BLOCKATTACK_WEB_SHELL}" + "SHELL:--preload-file ${BLOCKATTACK_DATA_FILE}@/blockattack.data" + "SHELL:--preload-file ${BLOCKATTACK_MOD_FILE}@/mods/1.3.0.bricks.data" + "SHELL:--preload-file ${BLOCKATTACK_LOCALE_DIR}@/locale") + set_property(TARGET blockattack APPEND PROPERTY LINK_DEPENDS + "${BLOCKATTACK_WEB_SHELL}" "${BLOCKATTACK_DATA_FILE}" "${BLOCKATTACK_MOD_FILE}") endif() -set_target_properties(blockattack PROPERTIES RUNTIME_OUTPUT_DIRECTORY Game) #Installing include(CPack) diff --git a/EMSCRIPTEN.md b/EMSCRIPTEN.md new file mode 100644 index 0000000..51c78c3 --- /dev/null +++ b/EMSCRIPTEN.md @@ -0,0 +1,15 @@ +## Building for the web (Emscripten) +The browser build uses the Emscripten SDK (tested with emcc 6.0). It does not change the native build: the SDL2 libraries come from Emscripten's ports and PhysFS is fetched and built from source. The first configure therefore needs network access. To build offline pass `-DFETCHCONTENT_SOURCE_DIR_PHYSFS=`. +```bash +source /emsdk_env.sh +./packdata.sh +emcmake cmake -S . -B build-web -DCMAKE_BUILD_TYPE=Release +cmake --build build-web +``` +The result is `build-web/web/index.html` with `index.js`, `index.wasm` and `index.data`. Browsers refuse to load wasm from `file://`, so serve the folder: +```bash +python3 -m http.server -d build-web/web 8080 +``` +and open . + +Settings and highscores are stored in the browser's IndexedDB for that origin. Command line options, the editors and screenshots are not available in the browser. diff --git a/README.md b/README.md index b432019..3dc4d4a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Recent Linux version. Target is all current Ubuntu Desktop LTS versions (up to 5 Target is all officially supported versions of Windows for x86-64 that allow manually installed software. Currently Windows 10+. ## Dependencies -* A version of g++ with C++17 support. Tested on g++-11 +* A version of g++ with C++20 support. Tested on g++-11 * libSDL2 * libSDL2_image * libSDL2_mixer diff --git a/source/code/editor/editor_loader.cpp b/source/code/editor/editor_loader.cpp index 643c38f..6acbce6 100644 --- a/source/code/editor/editor_loader.cpp +++ b/source/code/editor/editor_loader.cpp @@ -23,7 +23,6 @@ https://github.com/sago007/saland #include "editor_loader.hpp" #include "../sago/SagoMisc.hpp" -#include #include @@ -84,7 +83,7 @@ std::map LoadSprites() { std::map sprites; std::vector spritefiles = sago::GetFileList("sprites"); for (std::string& item : spritefiles ) { - if (boost::algorithm::ends_with(item,".sprite")) { + if (item.ends_with(".sprite")) { std::string filename = "sprites/"+item; std::string content = sago::GetFileContent(filename.c_str()); if (content.empty()) { diff --git a/source/code/gamecontroller.cpp b/source/code/gamecontroller.cpp index 2b41349..4c79960 100644 --- a/source/code/gamecontroller.cpp +++ b/source/code/gamecontroller.cpp @@ -97,11 +97,14 @@ void UnInitGameControllers() { } void InitGameControllers() { +#ifndef __EMSCRIPTEN__ + //The browser has no user config folder std::string configFile = sago::getConfigHome()+"/blockattack/gamecontrollerdb.txt"; int errorCode = SDL_GameControllerAddMappingsFromFile(configFile.c_str()); if (errorCode == -1 && verbose) { std::cerr << "Could not load mapping file: " << configFile << "\n"; } +#endif if (verbose) { std::cout << "Number of Game controllers: " << SDL_NumJoysticks() << "\n"; } diff --git a/source/code/main.cpp b/source/code/main.cpp index ec2183a..fa34676 100644 --- a/source/code/main.cpp +++ b/source/code/main.cpp @@ -61,7 +61,11 @@ https://blockattack.net #include "common.h" #include "gamecontroller.h" +#ifdef __EMSCRIPTEN__ +#include +#else #include +#endif #include #include "levelselect.hpp" @@ -82,9 +86,14 @@ GlobalData globalData; static int InitImages(sago::SagoSpriteHolder& holder); static void FsSearchParthMainAppend(std::vector& paths) { +#ifdef __EMSCRIPTEN__ + //The browser build preloads the data file here (CMakeLists.txt) + paths.push_back("/blockattack.data"); +#else paths.push_back((std::string)SHAREDIR+"/blockattack.data"); paths.push_back((std::string)PHYSFS_getBaseDir()+"/blockattack.data"); paths.push_back((std::string)PHYSFS_getBaseDir()+"/data"); +#endif } static void FsSearchPathModAppend(std::vector& paths, const std::vector& modlist) { @@ -361,6 +370,10 @@ std::string pathToScreenShots() { //writeScreenShot saves the screen as a bmp file, it uses the time to get a unique filename void writeScreenShot() { +#ifdef __EMSCRIPTEN__ + //The browser has no pictures folder to save to + return; +#endif if (globalData.verboseLevel) { std::cout << "Saving screenshot" << "\n"; } @@ -904,6 +917,11 @@ struct globalConfig { bool softwareRenderer = false; }; +#ifdef __EMSCRIPTEN__ +//The browser build has no command line +static void ParseArguments(int, char*[], globalConfig&) { +} +#else static void ParseArguments(int argc, char* argv[], globalConfig& conf) { int consoleWidth = boost::program_options::options_description::m_default_line_length; const char* columnsEnv = getenv("COLUMNS"); // Allows using "COLUMNS=300 help2man" for generating the man page without bad line breaks. @@ -1046,6 +1064,7 @@ static void ParseArguments(int argc, char* argv[], globalConfig& conf) { } } +#endif //Physfs 2.0.z does not have PHYSFS_unmount #if (PHYSFS_VER_MAJOR <= 2) && (PHYSFS_VER_MINOR < 1) @@ -1081,6 +1100,7 @@ int main(int argc, char* argv[]) { bind_textdomain_codeset(PACKAGE, "utf-8"); textdomain (PACKAGE); ParseArguments(argc, argv, config); + OsMountPersistentStorage(); OsCreateSaveFolder(); writeStateFile(argv[0], PHYSFS_getBaseDir()); PhysFsSetSearchPath(config.search_paths, config.savepath); @@ -1102,10 +1122,13 @@ int main(int argc, char* argv[]) { //Os create folders must be after the parameters because they can change the home folder PhysFsCreateFolders(); bool gameShutdownProperly = true; +#ifndef __EMSCRIPTEN__ + //A closed browser tab never runs the shutdown code, so the crash detection is skipped there if (sago::FileExists("gameRunning")) { gameShutdownProperly = false; } sago::WriteFileContent("gameRunning", "Started"); +#endif globalData.SoundEnabled = true; globalData.MusicEnabled = true; twoPlayers = false; //true if two players splitscreen @@ -1418,9 +1441,19 @@ int main(int argc, char* argv[]) { catch (std::exception& e) { sago::SagoFatalError(e.what()); } +#ifndef __EMSCRIPTEN__ PHYSFS_delete("gameRunning"); +#endif //Close file system Apstraction layer! PHYSFS_deinit(); +#ifdef __EMSCRIPTEN__ + //The page stays open with the last frame on the canvas, so tell the player what happened + EM_ASM({ + if (Module.setStatus) { + Module.setStatus('The game has been closed. Reload the page to play again.'); + } + }); +#endif return 0; } diff --git a/source/code/os.cpp b/source/code/os.cpp index 77e0ece..4ab6759 100644 --- a/source/code/os.cpp +++ b/source/code/os.cpp @@ -23,6 +23,8 @@ https://blockattack.net #include "os.hpp" #include +#include +#include #include #include "platform_folders.h" #include "version.h" @@ -34,7 +36,14 @@ https://blockattack.net #endif #include +#if defined(__EMSCRIPTEN__) +// Emscripten's getuid() stub reports root, which makes PlatformFolders look up +// a passwd entry that does not exist. The browser build uses a fixed path that +// only serves as the IDBFS mount point (see OsMountPersistentStorage). +static const char* const webSaveFolder = "/home/web_user/.local/share"; +#else static sago::PlatformFolders pf; +#endif static std::string overrideSavePath = ""; @@ -48,7 +57,11 @@ std::string getPathToSaveFiles() { if (overrideSavePath.length() > 0) { return overrideSavePath; } +#if defined(__EMSCRIPTEN__) + return std::string(webSaveFolder)+"/"+GAMENAME; +#else return pf.getSaveGamesFolder1()+"/"+GAMENAME; +#endif } std::string getPathToStateFiles() { @@ -91,7 +104,7 @@ std::string defaultPlayerName() { ret = sago::internal::win32_utf16_to_utf8(win_buffer); } #endif -#if defined(__unix__) +#if defined(__unix__) && !defined(__EMSCRIPTEN__) int uid = getuid(); struct passwd* pw = getpwuid(uid); if (pw && pw->pw_gecos) { @@ -117,7 +130,6 @@ bool OsPathIsRelative(const std::string& path) { } void OsCreateFolder(const std::string& path) { - //Once all supported systems works with C++17 then we can use "std::filesystem::create_directories" instead #if defined(_WIN32) //Now for Windows Vista+ std::vector element_vector; @@ -131,11 +143,40 @@ void OsCreateFolder(const std::string& path) { } CreateDirectoryW(win32_utf8_to_utf16(path.c_str()).c_str(), NULL); #else - std::string cmd = "mkdir -p '"+path+"/'"; - int retcode = system(cmd.c_str()); - if (retcode != 0) { - std::cerr << "Failed to create: " << path+"/" << "\n"; + std::error_code ec; + std::filesystem::create_directories(path, ec); + if (ec) { + std::cerr << "Failed to create: " << path+"/" << " (" << ec.message() << ")\n"; + } +#endif +} + +#if defined(__EMSCRIPTEN__) +#include + +// Mounts IndexedDB-backed storage at the save folder and pulls the stored +// files in. Must run before PhysFS gets its write dir so every existing +// write path (PhysFS and std::ofstream) lands in persistent storage. +// A failed mount is only logged: the folder still exists in the in-memory +// file system, so the game runs but nothing survives a reload. +EM_ASYNC_JS(int, os_web_mount_idbfs, (const char* cpath), { + const path = UTF8ToString(cpath); + try { + FS.mkdirTree(path); + FS.mount(FS.filesystems.IDBFS, {}, path); + await new Promise((resolve, reject) => FS.syncfs(true, (err) => err ? reject(err) : resolve())); + return 0; } + catch (e) { + console.error('Persistent storage unavailable, saves will not survive a reload: ' + e); + return -1; + } +}); +#endif + +void OsMountPersistentStorage() { +#if defined(__EMSCRIPTEN__) + os_web_mount_idbfs(getPathToSaveFiles().c_str()); #endif } diff --git a/source/code/os.hpp b/source/code/os.hpp index 05c124f..20c5934 100644 --- a/source/code/os.hpp +++ b/source/code/os.hpp @@ -41,6 +41,13 @@ void setPathToSaveFiles(const std::string& path); void OsCreateSaveFolder(); +/** + * In the browser build: mounts persistent (IndexedDB) storage at the save + * folder and loads the stored files. Must be called before the save folder + * is used. Does nothing on other platforms. + */ +void OsMountPersistentStorage(); + bool OsPathIsRelative(const std::string& path); /** diff --git a/source/code/sago/SagoMisc.cpp b/source/code/sago/SagoMisc.cpp index 08c92d6..db38434 100644 --- a/source/code/sago/SagoMisc.cpp +++ b/source/code/sago/SagoMisc.cpp @@ -33,6 +33,36 @@ SOFTWARE. #define PHYSFS_writeBytes(X,Y,Z) PHYSFS_write(X,Y,1,Z) #endif +#ifdef __EMSCRIPTEN__ +#include + +// Pushes the IDBFS mount to IndexedDB after a write. Fire-and-forget; +// overlapping requests are coalesced because Emscripten warns when several +// syncfs calls run at once. +EM_JS(void, sago_web_syncfs, (), { + const state = (Module.sagoSyncState ||= { inFlight: false, pending: false }); + function run() { + state.inFlight = true; + FS.syncfs(false, (err) => { + state.inFlight = false; + if (err) { + console.error('IDBFS sync failed: ' + err); + } + if (state.pending) { + state.pending = false; + run(); + } + }); + } + if (state.inFlight) { + state.pending = true; + } + else { + run(); + } +}); +#endif + namespace sago { @@ -109,6 +139,9 @@ void WriteFileContent(const char* filename, const std::string& content) { } PHYSFS_writeBytes(myfile, content.c_str(), sizeof(char)*content.length()); PHYSFS_close(myfile); +#ifdef __EMSCRIPTEN__ + sago_web_syncfs(); +#endif } long int StrToLong(const char* c_string) { diff --git a/source/code/sago/SagoMiscSdl2.cpp b/source/code/sago/SagoMiscSdl2.cpp index 8a3d294..155c883 100644 --- a/source/code/sago/SagoMiscSdl2.cpp +++ b/source/code/sago/SagoMiscSdl2.cpp @@ -24,8 +24,24 @@ SOFTWARE. #include "SagoMiscSdl2.hpp" #include "SDL.h" +#ifdef __EMSCRIPTEN__ +#include +#endif void sago::SagoFatalError(const char* errorMsg) { +#ifdef __EMSCRIPTEN__ + //SDL_ShowMessageBox is not implemented in the Emscripten video driver and + //alert() would block the page, so the message goes to the console and to + //the status overlay of the shell page + EM_ASM({ + var message = 'Fatal error: ' + UTF8ToString($0); + console.error(message); + if (Module.setStatus) { + Module.setStatus(message); + } + }, errorMsg); + abort(); +#else const SDL_MessageBoxButtonData buttons[] = { { SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, 0, "Ok" }, }; @@ -41,6 +57,7 @@ void sago::SagoFatalError(const char* errorMsg) { int buttonid; SDL_ShowMessageBox(&messageboxdata, &buttonid); abort(); +#endif } void sago::SagoFatalErrorF(const char* fmt, ...) { diff --git a/source/misc/web/shell.html b/source/misc/web/shell.html new file mode 100644 index 0000000..7bdbc33 --- /dev/null +++ b/source/misc/web/shell.html @@ -0,0 +1,81 @@ + + + + + + Block Attack - Rise of the Blocks + + + +
Loading...
Sound starts after the first click on the game.
+ + + {{{ SCRIPT }}} + +