commit 2007fb7edc699b020c36038221efc4d4749b05d3 Author: shweet Date: Sun Nov 8 01:30:33 2026 -0500 Initial commit Co-Authored-By: Claude Opus 5.5 diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..09fd2b5 --- /dev/null +++ b/.clang-format @@ -0,0 +1,32 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 120 +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: true + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: false + BeforeWhile: true + SplitEmptyFunction: false + SplitEmptyRecord: false +RemoveBracesLLVM: true +InsertBraces: false +SortIncludes: Never +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortLambdasOnASingleLine: Inline +AccessModifierOffset: -4 +IndentCaseLabels: true +NamespaceIndentation: None +PointerAlignment: Left diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3c19e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +build-check/ +build-web/ +__pycache__/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f1849ce --- /dev/null +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "external/sdl"] + path = external/sdl + url = https://github.com/libsdl-org/SDL.git + branch = release-3.4.x +[submodule "external/stb"] + path = external/stb + url = https://github.com/nothings/stb.git +[submodule "external/simdjson"] + path = external/simdjson + url = https://github.com/simdjson/simdjson +[submodule "external/libdatachannel"] + path = external/libdatachannel + url = https://github.com/paullouisageneau/libdatachannel.git +[submodule "external/datachannel-wasm"] + path = external/datachannel-wasm + url = https://github.com/paullouisageneau/datachannel-wasm.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25d40a3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +- C++, with Cmake. +- Third party libraries should be included as git submodules and built statically with the program; never dynamic. +- For something like SDL, make sure that only the build options only include stuff the program needs. +- Use snake_case for functions. +- Use SCREAMING_SNAKE_CASE for constants/constexprs. +- Use camelCase for variables +- Use snake_case for file names and folders. +- Variables should be declared on separate lines, even if they're the same type. +- Don't use magic numbers, except for the very common stuff like -1, 0, 1, 2. Constants should have a symbol attached to them. +- Use newlines to sort code "logically", into blocks, to be more readable. +- Newlines for { and }, except if the clause contained inside is one line, then exclude them. +- Booleans (both functions returning them and variables) should always be prepended with "is", even if it grammatically wouldn't make sense. +- Avoid use of anonymous namespaces. For namespaces, try to have them match files; don't create superfluous namespaces within documents. +- Case statements inside switches should be indented. +- Comments and other in-code writings should abstain from using semicolons to separate clauses. Prefer newlines and simple sentences. +- Prefer "manager" classes with a suite of functions as methods rather than ones operating on structs, for units that necessitate them. +- Sort struct members/variable declarations logically; try to clump same-typed variables together, in alphabetical order. +- Little to no comments; one at the top of structs/functions to explain broad behavior concisely. Code should be self-explaining. +- Keep function parameters low; prefer passing larger references rather than a number of parameters. +- Functions should use snake case and should typically be written like "[noun]_[verb]". Try and use a limited amount of verbs to describe functionality; "save", "load", etc. +- Avoid use of "const" unless needed (not constexprs); it really doesn't matter tbh. + +If user asks for "how to", or "why", that's an indicator to generate no code and simply answer their prompt. + +Always strive to cut down code whenever possible, and to reduce complexity, reuse functions and behaviors, etc. Identify places where line/file count can be trimmed. + +Performance, low compile time, and quick initialization of the program are high priorities. + +Relentlessly pause execution to ask the user questions about project architecture, and to guide them and help assist them understand the software being produced. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a8487ad --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.24) +project(smack_a_brother LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# The simulation must run identically on every platform (desktop and web play together), so floating point is kept +# strict: no fusing multiplies and adds (GCC and arm64 Clang do by default), no fast-math reordering. The maths +# library's transcendentals are replaced by src/dmath.h; tools/determinism_check.sh checks it all holds. +if(MSVC) + add_compile_options(/fp:precise /fp:contract-) +else() + add_compile_options(-ffp-contract=off -fno-fast-math) +endif() +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE RelWithDebInfo) +endif() + +# SDL: static, with only the video (OpenGL), audio and gamepad pieces the game uses. +set(SDL_SHARED OFF CACHE BOOL "" FORCE) +set(SDL_STATIC ON CACHE BOOL "" FORCE) +set(SDL_OPENGL ON CACHE BOOL "" FORCE) +set(SDL_JOYSTICK ON CACHE BOOL "" FORCE) +set(SDL_HIDAPI ON CACHE BOOL "" FORCE) +set(SDL_VIRTUAL_JOYSTICK ON CACHE BOOL "" FORCE) +set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) +set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE) +foreach(OPTION RENDER GPU VULKAN CAMERA HAPTIC HIDAPI_LIBUSB POWER SENSOR DIALOG TRAY OPENGLES OFFSCREEN + DUMMYVIDEO DISKAUDIO DUMMYAUDIO OSS JACK SNDIO KMSDRM IBUS LIBURING) + set(SDL_${OPTION} OFF CACHE BOOL "" FORCE) +endforeach() +add_subdirectory(external/sdl EXCLUDE_FROM_ALL) + +# simdjson: its single-file build, static, without exceptions. +add_library(simdjson STATIC external/simdjson/singleheader/simdjson.cpp) +target_include_directories(simdjson SYSTEM PUBLIC external/simdjson/singleheader) +target_compile_definitions(simdjson PUBLIC SIMDJSON_EXCEPTIONS=0) + +# Networking: WebRTC data channels between players and a WebSocket to the server. On desktop it's +# libdatachannel (static, data channels only, no media); in the browser, datachannel-wasm gives the same API over the +# browser's own WebRTC. +if(EMSCRIPTEN) + add_subdirectory(external/datachannel-wasm EXCLUDE_FROM_ALL) + set(NETWORK_LIBRARY datachannel-wasm) +else() + set(NO_MEDIA ON CACHE BOOL "" FORCE) + set(NO_EXAMPLES ON CACHE BOOL "" FORCE) + set(NO_TESTS ON CACHE BOOL "" FORCE) + add_subdirectory(external/libdatachannel EXCLUDE_FROM_ALL) + set(NETWORK_LIBRARY LibDataChannel::LibDataChannelStatic) + # The server, which can also be built on its own (see server/CMakeLists.txt). + add_subdirectory(server) +endif() + +file(GLOB SOURCES CONFIGURE_DEPENDS src/*.cpp) +add_executable(smack_a_brother ${SOURCES}) +target_include_directories(smack_a_brother SYSTEM PRIVATE external/stb) +target_link_libraries(smack_a_brother PRIVATE SDL3::SDL3-static simdjson ${NETWORK_LIBRARY}) +set_target_properties(smack_a_brother PROPERTIES OUTPUT_NAME smack-a-brother) + +file(CREATE_LINK ${CMAKE_SOURCE_DIR}/resources ${CMAKE_BINARY_DIR}/resources SYMBOLIC) + +# Emscripten: RPG_NODE builds a headless copy for node (balance runs and the determinism check), with the resources +# embedded. +if(EMSCRIPTEN) + option(RPG_NODE "Build for node, headless" OFF) + target_link_options(smack_a_brother PRIVATE -sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=1048576) + if(NOT RPG_NODE) + # The browser build: WebGL2, the resources preloaded, the prefs folder kept in browser storage, and a page + # whose canvas fills the window. + set_target_properties(smack_a_brother PROPERTIES SUFFIX ".html") + target_link_options(smack_a_brother PRIVATE -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 -lidbfs.js + "--preload-file=${CMAKE_SOURCE_DIR}/resources/gfx@/resources/gfx" + "--preload-file=${CMAKE_SOURCE_DIR}/resources/font@/resources/font" + "--preload-file=${CMAKE_SOURCE_DIR}/resources/sfx@/resources/sfx" + "--shell-file=${CMAKE_SOURCE_DIR}/tools/web_shell.html" + "--pre-js=${CMAKE_SOURCE_DIR}/tools/web_pre.js") + endif() + if(RPG_NODE) + target_link_options(smack_a_brother PRIVATE -sENVIRONMENT=node -sEXIT_RUNTIME=1 + "--pre-js=${CMAKE_SOURCE_DIR}/tools/node_pre.js" + "--embed-file=${CMAKE_SOURCE_DIR}/resources/gfx@/resources/gfx" + "--embed-file=${CMAKE_SOURCE_DIR}/resources/font@/resources/font") + endif() +endif() diff --git a/external/datachannel-wasm b/external/datachannel-wasm new file mode 160000 index 0000000..3d88eb8 --- /dev/null +++ b/external/datachannel-wasm @@ -0,0 +1 @@ +Subproject commit 3d88eb88fb7d6729e498079477dd3ae46d30ac38 diff --git a/external/libdatachannel b/external/libdatachannel new file mode 160000 index 0000000..6b1e2e6 --- /dev/null +++ b/external/libdatachannel @@ -0,0 +1 @@ +Subproject commit 6b1e2e620f1e37f0eafeee702eaea0043cb305fd diff --git a/external/sdl b/external/sdl new file mode 160000 index 0000000..32e9d37 --- /dev/null +++ b/external/sdl @@ -0,0 +1 @@ +Subproject commit 32e9d379f62ed0f2f092c269967f557c307431e8 diff --git a/external/simdjson b/external/simdjson new file mode 160000 index 0000000..d3c409e --- /dev/null +++ b/external/simdjson @@ -0,0 +1 @@ +Subproject commit d3c409ec4dd689bf22ef0c3766f2f08d0e41fab3 diff --git a/external/stb b/external/stb new file mode 160000 index 0000000..d14d46f --- /dev/null +++ b/external/stb @@ -0,0 +1 @@ +Subproject commit d14d46f6b44456eb88beac58cba833a4135d4c89 diff --git a/resources/font/big.fnt b/resources/font/big.fnt new file mode 100644 index 0000000..0d7b6a8 --- /dev/null +++ b/resources/font/big.fnt @@ -0,0 +1,103 @@ +{ + "image": "big.png", + "scale": 2, + "lineHeight": 24, + "outline": 2, + "glyphs": { + " ": {"x": 0, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "!": {"x": 23, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 8}, + "\"": {"x": 46, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 8}, + "#": {"x": 69, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "$": {"x": 92, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "%": {"x": 115, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 18}, + "&": {"x": 138, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 18}, + "'": {"x": 161, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "(": {"x": 184, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + ")": {"x": 207, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "*": {"x": 230, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "+": {"x": 253, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + ",": {"x": 276, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "-": {"x": 299, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + ".": {"x": 322, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "/": {"x": 345, "y": 0, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "0": {"x": 0, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "1": {"x": 23, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 8}, + "2": {"x": 46, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "3": {"x": 69, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "4": {"x": 92, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "5": {"x": 115, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "6": {"x": 138, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "7": {"x": 161, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "8": {"x": 184, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "9": {"x": 207, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + ":": {"x": 230, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + ";": {"x": 253, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "<": {"x": 276, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "=": {"x": 299, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + ">": {"x": 322, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "?": {"x": 345, "y": 29, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "@": {"x": 0, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 22}, + "A": {"x": 23, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "B": {"x": 46, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "C": {"x": 69, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "D": {"x": 92, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "E": {"x": 115, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "F": {"x": 138, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "G": {"x": 161, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "H": {"x": 184, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "I": {"x": 207, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "J": {"x": 230, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "K": {"x": 253, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "L": {"x": 276, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "M": {"x": 299, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "N": {"x": 322, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "O": {"x": 345, "y": 58, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "P": {"x": 0, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "Q": {"x": 23, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "R": {"x": 46, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "S": {"x": 69, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "T": {"x": 92, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "U": {"x": 115, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "V": {"x": 138, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "W": {"x": 161, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "X": {"x": 184, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "Y": {"x": 207, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "Z": {"x": 230, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "[": {"x": 253, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "\\": {"x": 276, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "]": {"x": 299, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "^": {"x": 322, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "_": {"x": 345, "y": 87, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "`": {"x": 0, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 8}, + "a": {"x": 23, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "b": {"x": 46, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "c": {"x": 69, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "d": {"x": 92, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "e": {"x": 115, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "f": {"x": 138, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "g": {"x": 161, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "h": {"x": 184, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "i": {"x": 207, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "j": {"x": 230, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "k": {"x": 253, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "l": {"x": 276, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "m": {"x": 299, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 18}, + "n": {"x": 322, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "o": {"x": 345, "y": 116, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "p": {"x": 0, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "q": {"x": 23, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "r": {"x": 46, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "s": {"x": 69, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "t": {"x": 92, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 8}, + "u": {"x": 115, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "v": {"x": 138, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "w": {"x": 161, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 16}, + "x": {"x": 184, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "y": {"x": 207, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "z": {"x": 230, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 12}, + "{": {"x": 253, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "|": {"x": 276, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, + "}": {"x": 299, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "~": {"x": 322, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14} + } +} diff --git a/resources/font/big.png b/resources/font/big.png new file mode 100644 index 0000000..4296bbb Binary files /dev/null and b/resources/font/big.png differ diff --git a/resources/font/huge.fnt b/resources/font/huge.fnt new file mode 100644 index 0000000..d7c1488 --- /dev/null +++ b/resources/font/huge.fnt @@ -0,0 +1,103 @@ +{ + "image": "huge.png", + "scale": 3, + "lineHeight": 36, + "outline": 3, + "glyphs": { + " ": {"x": 0, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "!": {"x": 34, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 12}, + "\"": {"x": 68, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 12}, + "#": {"x": 102, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "$": {"x": 136, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "%": {"x": 170, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 27}, + "&": {"x": 204, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 27}, + "'": {"x": 238, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "(": {"x": 272, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + ")": {"x": 306, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "*": {"x": 340, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "+": {"x": 374, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + ",": {"x": 408, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "-": {"x": 442, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + ".": {"x": 476, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "/": {"x": 510, "y": 0, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "0": {"x": 0, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "1": {"x": 34, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 12}, + "2": {"x": 68, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "3": {"x": 102, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "4": {"x": 136, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "5": {"x": 170, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "6": {"x": 204, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "7": {"x": 238, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "8": {"x": 272, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "9": {"x": 306, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + ":": {"x": 340, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + ";": {"x": 374, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "<": {"x": 408, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "=": {"x": 442, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + ">": {"x": 476, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "?": {"x": 510, "y": 43, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "@": {"x": 0, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 33}, + "A": {"x": 34, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "B": {"x": 68, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "C": {"x": 102, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "D": {"x": 136, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "E": {"x": 170, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "F": {"x": 204, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "G": {"x": 238, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "H": {"x": 272, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "I": {"x": 306, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "J": {"x": 340, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "K": {"x": 374, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "L": {"x": 408, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "M": {"x": 442, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "N": {"x": 476, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "O": {"x": 510, "y": 86, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "P": {"x": 0, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "Q": {"x": 34, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "R": {"x": 68, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "S": {"x": 102, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "T": {"x": 136, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "U": {"x": 170, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "V": {"x": 204, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "W": {"x": 238, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "X": {"x": 272, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "Y": {"x": 306, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "Z": {"x": 340, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "[": {"x": 374, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "\\": {"x": 408, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "]": {"x": 442, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "^": {"x": 476, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "_": {"x": 510, "y": 129, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "`": {"x": 0, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 12}, + "a": {"x": 34, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "b": {"x": 68, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "c": {"x": 102, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "d": {"x": 136, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "e": {"x": 170, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "f": {"x": 204, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "g": {"x": 238, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "h": {"x": 272, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "i": {"x": 306, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "j": {"x": 340, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "k": {"x": 374, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "l": {"x": 408, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "m": {"x": 442, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 27}, + "n": {"x": 476, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "o": {"x": 510, "y": 172, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "p": {"x": 0, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "q": {"x": 34, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "r": {"x": 68, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "s": {"x": 102, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "t": {"x": 136, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 12}, + "u": {"x": 170, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "v": {"x": 204, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "w": {"x": 238, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 24}, + "x": {"x": 272, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "y": {"x": 306, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "z": {"x": 340, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 18}, + "{": {"x": 374, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "|": {"x": 408, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, + "}": {"x": 442, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "~": {"x": 476, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21} + } +} diff --git a/resources/font/huge.png b/resources/font/huge.png new file mode 100644 index 0000000..00e2c10 Binary files /dev/null and b/resources/font/huge.png differ diff --git a/resources/font/pixel_purl.ttf b/resources/font/pixel_purl.ttf new file mode 100644 index 0000000..167680b Binary files /dev/null and b/resources/font/pixel_purl.ttf differ diff --git a/resources/font/small.fnt b/resources/font/small.fnt new file mode 100644 index 0000000..f9d6022 --- /dev/null +++ b/resources/font/small.fnt @@ -0,0 +1,103 @@ +{ + "image": "small.png", + "scale": 1, + "lineHeight": 12, + "outline": 1, + "glyphs": { + " ": {"x": 0, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "!": {"x": 12, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 4}, + "\"": {"x": 24, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 4}, + "#": {"x": 36, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "$": {"x": 48, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "%": {"x": 60, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 9}, + "&": {"x": 72, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 9}, + "'": {"x": 84, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "(": {"x": 96, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + ")": {"x": 108, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "*": {"x": 120, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "+": {"x": 132, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + ",": {"x": 144, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "-": {"x": 156, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + ".": {"x": 168, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "/": {"x": 180, "y": 0, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "0": {"x": 0, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "1": {"x": 12, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 4}, + "2": {"x": 24, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "3": {"x": 36, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "4": {"x": 48, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "5": {"x": 60, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "6": {"x": 72, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "7": {"x": 84, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "8": {"x": 96, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "9": {"x": 108, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + ":": {"x": 120, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + ";": {"x": 132, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "<": {"x": 144, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "=": {"x": 156, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + ">": {"x": 168, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "?": {"x": 180, "y": 15, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "@": {"x": 0, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 11}, + "A": {"x": 12, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "B": {"x": 24, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "C": {"x": 36, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "D": {"x": 48, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "E": {"x": 60, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "F": {"x": 72, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "G": {"x": 84, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "H": {"x": 96, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "I": {"x": 108, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "J": {"x": 120, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "K": {"x": 132, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "L": {"x": 144, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "M": {"x": 156, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "N": {"x": 168, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "O": {"x": 180, "y": 30, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "P": {"x": 0, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "Q": {"x": 12, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "R": {"x": 24, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "S": {"x": 36, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "T": {"x": 48, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "U": {"x": 60, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "V": {"x": 72, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "W": {"x": 84, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "X": {"x": 96, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "Y": {"x": 108, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "Z": {"x": 120, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "[": {"x": 132, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "\\": {"x": 144, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "]": {"x": 156, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "^": {"x": 168, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "_": {"x": 180, "y": 45, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "`": {"x": 0, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 4}, + "a": {"x": 12, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "b": {"x": 24, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "c": {"x": 36, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "d": {"x": 48, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "e": {"x": 60, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "f": {"x": 72, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "g": {"x": 84, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "h": {"x": 96, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "i": {"x": 108, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "j": {"x": 120, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "k": {"x": 132, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "l": {"x": 144, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "m": {"x": 156, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 9}, + "n": {"x": 168, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "o": {"x": 180, "y": 60, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "p": {"x": 0, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "q": {"x": 12, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "r": {"x": 24, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "s": {"x": 36, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "t": {"x": 48, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 4}, + "u": {"x": 60, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "v": {"x": 72, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "w": {"x": 84, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 8}, + "x": {"x": 96, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "y": {"x": 108, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "z": {"x": 120, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 6}, + "{": {"x": 132, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "|": {"x": 144, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, + "}": {"x": 156, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "~": {"x": 168, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7} + } +} diff --git a/resources/font/small.png b/resources/font/small.png new file mode 100644 index 0000000..305401f Binary files /dev/null and b/resources/font/small.png differ diff --git a/resources/font/tiny.fnt b/resources/font/tiny.fnt new file mode 100644 index 0000000..1ddad17 --- /dev/null +++ b/resources/font/tiny.fnt @@ -0,0 +1,102 @@ +{ + "image": "tiny.png", + "lineHeight": 8, + "outline": 1, + "glyphs": { + " ": {"x": 0, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "!": {"x": 6, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "\"": {"x": 12, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "#": {"x": 18, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 6}, + "$": {"x": 24, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "%": {"x": 30, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "&": {"x": 36, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "'": {"x": 42, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "(": {"x": 48, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + ")": {"x": 54, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "*": {"x": 60, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "+": {"x": 66, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + ",": {"x": 72, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "-": {"x": 78, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + ".": {"x": 84, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "/": {"x": 90, "y": 0, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "0": {"x": 0, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "1": {"x": 6, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "2": {"x": 12, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "3": {"x": 18, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "4": {"x": 24, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "5": {"x": 30, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "6": {"x": 36, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "7": {"x": 42, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "8": {"x": 48, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "9": {"x": 54, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + ":": {"x": 60, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + ";": {"x": 66, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "<": {"x": 72, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "=": {"x": 78, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + ">": {"x": 84, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "?": {"x": 90, "y": 9, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "@": {"x": 0, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "A": {"x": 6, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "B": {"x": 12, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "C": {"x": 18, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "D": {"x": 24, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "E": {"x": 30, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "F": {"x": 36, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "G": {"x": 42, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "H": {"x": 48, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "I": {"x": 54, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "J": {"x": 60, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "K": {"x": 66, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "L": {"x": 72, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "M": {"x": 78, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 6}, + "N": {"x": 84, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "O": {"x": 90, "y": 18, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "P": {"x": 0, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "Q": {"x": 6, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "R": {"x": 12, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "S": {"x": 18, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "T": {"x": 24, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "U": {"x": 30, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "V": {"x": 36, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "W": {"x": 42, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 6}, + "X": {"x": 48, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "Y": {"x": 54, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "Z": {"x": 60, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "[": {"x": 66, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "\\": {"x": 72, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "]": {"x": 78, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "^": {"x": 84, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "_": {"x": 90, "y": 27, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "`": {"x": 0, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "a": {"x": 6, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "b": {"x": 12, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "c": {"x": 18, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "d": {"x": 24, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "e": {"x": 30, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "f": {"x": 36, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "g": {"x": 42, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "h": {"x": 48, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "i": {"x": 54, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "j": {"x": 60, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "k": {"x": 66, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "l": {"x": 72, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 3}, + "m": {"x": 78, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 6}, + "n": {"x": 84, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "o": {"x": 90, "y": 36, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "p": {"x": 0, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "q": {"x": 6, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "r": {"x": 12, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "s": {"x": 18, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "t": {"x": 24, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "u": {"x": 30, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "v": {"x": 36, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "w": {"x": 42, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 6}, + "x": {"x": 48, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "y": {"x": 54, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "z": {"x": 60, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "{": {"x": 66, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "|": {"x": 72, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, + "}": {"x": 78, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "~": {"x": 84, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5} + } +} diff --git a/resources/font/tiny.png b/resources/font/tiny.png new file mode 100644 index 0000000..b84e6ef Binary files /dev/null and b/resources/font/tiny.png differ diff --git a/resources/gfx/dummy.anm2 b/resources/gfx/dummy.anm2 new file mode 100644 index 0000000..b9d44f3 --- /dev/null +++ b/resources/gfx/dummy.anm2 @@ -0,0 +1,29 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "dummy.png", + "regions": [ + {"name": "Idle", "crop": [1, 1], "size": [29, 30], "pivot": [14, 30]} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Idle", + "animations": { + "Idle": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 0} + ] + } + ] + } + } +} diff --git a/resources/gfx/dummy.png b/resources/gfx/dummy.png new file mode 100644 index 0000000..d3bd1ad Binary files /dev/null and b/resources/gfx/dummy.png differ diff --git a/resources/gfx/effects.anm2 b/resources/gfx/effects.anm2 new file mode 100644 index 0000000..9470054 --- /dev/null +++ b/resources/gfx/effects.anm2 @@ -0,0 +1,62 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "effects.png", + "regions": [ + {"name": "Arrow", "crop": [5, 19], "size": [21, 9], "pivot": [10.5, 4.5], "origin": "center"}, + {"name": "Bullet", "crop": [43, 19], "size": [9, 9], "pivot": [4.5, 4.5], "origin": "center"}, + {"name": "Chain", "crop": [75, 19], "size": [10, 10], "pivot": [5, 5], "origin": "center"}, + {"name": "Flail Ball", "crop": [102, 14], "size": [19, 19], "pivot": [9.5, 9.5], "origin": "center"}, + {"name": "Flame 1", "crop": [9, 64], "size": [13, 15], "pivot": [6.5, 9.5]}, + {"name": "Flame 2", "crop": [41, 64], "size": [13, 15], "pivot": [6.5, 9.5]}, + {"name": "Flame 3", "crop": [73, 64], "size": [13, 15], "pivot": [6.5, 9.5]}, + {"name": "Flame 4", "crop": [105, 64], "size": [13, 15], "pivot": [6.5, 9.5]}, + {"name": "Mine", "crop": [7, 115], "size": [17, 10], "pivot": [8.5, 5], "origin": "center"}, + {"name": "Rocket", "crop": [40, 115], "size": [15, 9], "pivot": [7.5, 4.5], "origin": "center"}, + {"name": "Slash", "crop": [65, 96], "size": [30, 48], "pivot": [15, 24], "origin": "center"}, + {"name": "Slug", "crop": [106, 117], "size": [11, 5], "pivot": [5.5, 2.5], "origin": "center"}, + {"name": "Whip Tip", "crop": [11, 163], "size": [9, 9], "pivot": [0, 4.5]}, + {"name": "Punch", "crop": [34, 161], "size": [26, 14], "pivot": [19, 7]}, + {"name": "Uppercut", "crop": [64, 144], "size": [32, 48], "pivot": [17, 22]}, + {"name": "Wisp 1", "crop": [4, 193], "size": [24, 15], "pivot": [17, 7.5]}, + {"name": "Wisp 2", "crop": [36, 193], "size": [24, 15], "pivot": [17, 7.5]}, + {"name": "Wisp 3", "crop": [68, 193], "size": [24, 15], "pivot": [17, 7.5]}, + {"name": "Wisp 4", "crop": [100, 193], "size": [24, 15], "pivot": [17, 7.5]} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Arrow", + "animations": { + "Arrow": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, + "Bullet": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, + "Chain": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, + "Flail Ball": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, + "Flame": { + "length": 16, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 4, "delay": 4}, + {"region": 5, "delay": 4}, + {"region": 6, "delay": 4}, + {"region": 7, "delay": 4} + ] + } + ] + }, + "Mine": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Rocket": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Slash": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Slug": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Whip Tip": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Punch": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Uppercut": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Wisp": {"length": 16, "layers": [{"layer": 0, "frames": [{"region": 15, "delay": 4}, {"region": 16, "delay": 4}, {"region": 17, "delay": 4}, {"region": 18, "delay": 4}]}]} + } +} diff --git a/resources/gfx/effects.png b/resources/gfx/effects.png new file mode 100644 index 0000000..b6935eb Binary files /dev/null and b/resources/gfx/effects.png differ diff --git a/resources/gfx/modes.anm2 b/resources/gfx/modes.anm2 new file mode 100644 index 0000000..51f37b5 --- /dev/null +++ b/resources/gfx/modes.anm2 @@ -0,0 +1,71 @@ +{ + "anm2ed": 1, + "info": { + "fps": 60 + }, + "spritesheets": [ + { + "path": "modes.png", + "regions": [ + { + "name": "Deathmatch", + "crop": [ + 0, + 0 + ], + "size": [ + 24, + 24 + ], + "origin": "center" + }, + { + "name": "Race", + "crop": [ + 24, + 0 + ], + "size": [ + 24, + 24 + ], + "origin": "center" + } + ] + } + ], + "layers": [ + { + "name": "sprite" + } + ], + "defaultAnimation": "Deathmatch", + "animations": { + "Deathmatch": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 0 + } + ] + } + ] + }, + "Race": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 1 + } + ] + } + ] + } + } +} diff --git a/resources/gfx/modes.png b/resources/gfx/modes.png new file mode 100644 index 0000000..29557a4 Binary files /dev/null and b/resources/gfx/modes.png differ diff --git a/resources/gfx/player.anm2 b/resources/gfx/player.anm2 new file mode 100644 index 0000000..3f092ce --- /dev/null +++ b/resources/gfx/player.anm2 @@ -0,0 +1,89 @@ +{ + "anm2ed": 1, + "info": {}, + "spritesheets": [ + { + "path": "player.png", + "regions": [ + {"name": "Idle", "size": [16, 32], "pivot": [8, 32]}, + {"name": "Walk 1", "crop": [16, 0], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Walk 2", "crop": [32, 0], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Walk 3", "crop": [48, 0], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Walk 4", "crop": [64, 0], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Jump", "crop": [0, 32], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Fall", "crop": [16, 32], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Slide", "crop": [32, 32], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Kick", "crop": [48, 32], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Hurt", "crop": [64, 32], "size": [16, 32], "pivot": [8, 20]}, + {"name": "Crouch", "crop": [0, 64], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Roll", "crop": [0, 64], "size": [16, 32], "pivot": [8, 22]}, + {"name": "Crawl 1", "crop": [16, 64], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Crawl 2", "crop": [32, 64], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Crawl 3", "crop": [48, 64], "size": [16, 32], "pivot": [8, 32]}, + {"name": "Crawl 4", "crop": [64, 64], "size": [16, 32], "pivot": [8, 32]} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Idle", + "animations": { + "Idle": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, + "Walk Left": { + "length": 4, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1, "interpolation": "linear"}, + {"region": 2, "interpolation": "linear"}, + {"region": 3, "interpolation": "linear"}, + {"region": 4, "interpolation": "linear"} + ] + } + ] + }, + "Walk Right": { + "length": 4, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1, "scale": [-100, 100], "interpolation": "linear"}, + {"region": 2, "scale": [-100, 100], "interpolation": "linear"}, + {"region": 3, "scale": [-100, 100], "interpolation": "linear"}, + {"region": 4, "scale": [-100, 100], "interpolation": "linear"} + ] + } + ] + }, + "Jump": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, + "Fall": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, + "Slide": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, + "Kick": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Hurt": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Crouch": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Roll": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Crawl Left": { + "length": 4, + "layers": [ + {"layer": 0, "frames": [{"region": 12}, {"region": 13}, {"region": 14}, {"region": 15}]} + ] + }, + "Crawl Right": { + "length": 4, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 12, "scale": [-100, 100]}, + {"region": 13, "scale": [-100, 100]}, + {"region": 14, "scale": [-100, 100]}, + {"region": 15, "scale": [-100, 100]} + ] + } + ] + } + } +} diff --git a/resources/gfx/player.png b/resources/gfx/player.png new file mode 100644 index 0000000..ad817c6 Binary files /dev/null and b/resources/gfx/player.png differ diff --git a/resources/gfx/tiles.anm2 b/resources/gfx/tiles.anm2 new file mode 100644 index 0000000..61cca7d --- /dev/null +++ b/resources/gfx/tiles.anm2 @@ -0,0 +1,120 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "tiles.png", + "regions": [ + {"name": "Wall", "crop": [0, 0], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up", "crop": [16, 0], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Right", "crop": [32, 0], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Right", "crop": [48, 0], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Down", "crop": [0, 16], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Down", "crop": [16, 16], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Right Down", "crop": [32, 16], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Right Down", "crop": [48, 16], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Left", "crop": [0, 32], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Left", "crop": [16, 32], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Right Left", "crop": [32, 32], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Right Left", "crop": [48, 32], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Down Left", "crop": [0, 48], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Down Left", "crop": [16, 48], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Right Down Left", "crop": [32, 48], "size": [16, 16], "origin": "topLeft"}, + {"name": "Wall Open Up Right Down Left", "crop": [48, 48], "size": [16, 16], "origin": "topLeft"}, + {"name": "Platform Middle", "crop": [0, 64], "size": [16, 16], "origin": "topLeft"}, + {"name": "Platform Right End", "crop": [16, 64], "size": [16, 16], "origin": "topLeft"}, + {"name": "Platform Left End", "crop": [32, 64], "size": [16, 16], "origin": "topLeft"}, + {"name": "Platform Single", "crop": [48, 64], "size": [16, 16], "origin": "topLeft"}, + {"name": "Block", "crop": [0, 80], "size": [16, 16], "origin": "topLeft"}, + {"name": "Block Cracked", "crop": [16, 80], "size": [16, 16], "origin": "topLeft"}, + {"name": "Block Crumbling", "crop": [32, 80], "size": [16, 16], "origin": "topLeft"}, + {"name": "Pit", "crop": [48, 80], "size": [16, 16], "origin": "topLeft"}, + {"name": "Finish", "crop": [0, 96], "size": [16, 16], "origin": "topLeft"} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Wall", + "animations": { + "Wall": { + "length": 16, + "loop": false, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 0}, + {"region": 1}, + {"region": 2}, + {"region": 3}, + {"region": 4}, + {"region": 5}, + {"region": 6}, + {"region": 7}, + {"region": 8}, + {"region": 9}, + {"region": 10}, + {"region": 11}, + {"region": 12}, + {"region": 13}, + {"region": 14}, + {"region": 15} + ] + } + ] + }, + "Platform": { + "length": 4, + "loop": false, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 16}, + {"region": 17}, + {"region": 18}, + {"region": 19} + ] + } + ] + }, + "Block": { + "length": 3, + "loop": false, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 20}, + {"region": 21}, + {"region": 22} + ] + } + ] + }, + "Pit": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 23} + ] + } + ] + }, + "Finish": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 24} + ] + } + ] + } + } +} diff --git a/resources/gfx/tiles.png b/resources/gfx/tiles.png new file mode 100644 index 0000000..ff7e06f Binary files /dev/null and b/resources/gfx/tiles.png differ diff --git a/resources/gfx/trinkets.anm2 b/resources/gfx/trinkets.anm2 new file mode 100644 index 0000000..3c3c291 --- /dev/null +++ b/resources/gfx/trinkets.anm2 @@ -0,0 +1,241 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "trinkets.png", + "regions": [ + {"name": "Gloves", "crop": [0, 0], "size": [24, 24], "origin": "center"}, + {"name": "Energy Drink", "crop": [24, 0], "size": [24, 24], "origin": "center"}, + {"name": "Accelerator", "crop": [48, 0], "size": [24, 24], "origin": "center"}, + {"name": "Enlarger", "crop": [72, 0], "size": [24, 24], "origin": "center"}, + {"name": "Boxing Glove", "crop": [96, 0], "size": [24, 24], "origin": "center"}, + {"name": "Splitter", "crop": [120, 0], "size": [24, 24], "origin": "center"}, + {"name": "Doubler", "crop": [0, 24], "size": [24, 24], "origin": "center"}, + {"name": "Dual Wield", "crop": [24, 24], "size": [24, 24], "origin": "center"}, + {"name": "Ricochet", "crop": [48, 24], "size": [24, 24], "origin": "center"}, + {"name": "Tesla Coil", "crop": [72, 24], "size": [24, 24], "origin": "center"}, + {"name": "Boots", "crop": [96, 24], "size": [24, 24], "origin": "center"}, + {"name": "Speedometer", "crop": [120, 24], "size": [24, 24], "origin": "center"}, + {"name": "Shrinker", "crop": [0, 48], "size": [24, 24], "origin": "center"}, + {"name": "Wings", "crop": [24, 48], "size": [24, 24], "origin": "center"}, + {"name": "Feather", "crop": [48, 48], "size": [24, 24], "origin": "center"}, + {"name": "Iron Weights", "crop": [72, 48], "size": [24, 24], "origin": "center"}, + {"name": "Climb Claw", "crop": [96, 48], "size": [24, 24], "origin": "center"}, + {"name": "Recoil Coil", "crop": [120, 48], "size": [24, 24], "origin": "center"}, + {"name": "Diazepam", "crop": [0, 72], "size": [24, 24], "origin": "center"}, + {"name": "Embiggener", "crop": [24, 72], "size": [24, 24], "origin": "center"}, + {"name": "Pebble Helmet", "crop": [48, 72], "size": [24, 24], "origin": "center"}, + {"name": "Shock Absorber", "crop": [72, 72], "size": [24, 24], "origin": "center"} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Gloves", + "animations": { + "Gloves": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 0} + ] + } + ] + }, + "Energy Drink": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1} + ] + } + ] + }, + "Accelerator": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 2} + ] + } + ] + }, + "Enlarger": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 3} + ] + } + ] + }, + "Boxing Glove": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 4} + ] + } + ] + }, + "Splitter": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 5} + ] + } + ] + }, + "Doubler": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 6} + ] + } + ] + }, + "Dual Wield": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 7} + ] + } + ] + }, + "Ricochet": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 8} + ] + } + ] + }, + "Tesla Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 9} + ] + } + ] + }, + "Boots": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 10} + ] + } + ] + }, + "Speedometer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 11} + ] + } + ] + }, + "Shrinker": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 12} + ] + } + ] + }, + "Wings": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 13} + ] + } + ] + }, + "Feather": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 14} + ] + } + ] + }, + "Iron Weights": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 15} + ] + } + ] + }, + "Climb Claw": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 16} + ] + } + ] + }, + "Recoil Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 17} + ] + } + ] + }, + "Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, + "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]} + } +} diff --git a/resources/gfx/trinkets.png b/resources/gfx/trinkets.png new file mode 100644 index 0000000..8984312 Binary files /dev/null and b/resources/gfx/trinkets.png differ diff --git a/resources/gfx/trinkets_small.anm2 b/resources/gfx/trinkets_small.anm2 new file mode 100644 index 0000000..c1b4278 --- /dev/null +++ b/resources/gfx/trinkets_small.anm2 @@ -0,0 +1,567 @@ +{ + "anm2ed": 1, + "info": { + "fps": 60 + }, + "spritesheets": [ + { + "path": "trinkets_small.png", + "regions": [ + { + "name": "Gloves", + "crop": [ + 0, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Energy Drink", + "crop": [ + 16, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Accelerator", + "crop": [ + 32, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Enlarger", + "crop": [ + 48, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Boxing Glove", + "crop": [ + 64, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Splitter", + "crop": [ + 80, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Doubler", + "crop": [ + 0, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Dual Wield", + "crop": [ + 16, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Ricochet", + "crop": [ + 32, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Tesla Coil", + "crop": [ + 48, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Boots", + "crop": [ + 64, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Speedometer", + "crop": [ + 80, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Shrinker", + "crop": [ + 0, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Wings", + "crop": [ + 16, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Feather", + "crop": [ + 32, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Iron Weights", + "crop": [ + 48, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Climb Claw", + "crop": [ + 64, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Recoil Coil", + "crop": [ + 80, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Diazepam", + "crop": [ + 0, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Embiggener", + "crop": [ + 16, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Pebble Helmet", + "crop": [ + 32, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Shock Absorber", + "crop": [ + 48, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + } + ] + } + ], + "layers": [ + { + "name": "sprite" + } + ], + "defaultAnimation": "Gloves", + "animations": { + "Gloves": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 0 + } + ] + } + ] + }, + "Energy Drink": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 1 + } + ] + } + ] + }, + "Accelerator": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 2 + } + ] + } + ] + }, + "Enlarger": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 3 + } + ] + } + ] + }, + "Boxing Glove": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 4 + } + ] + } + ] + }, + "Splitter": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 5 + } + ] + } + ] + }, + "Doubler": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 6 + } + ] + } + ] + }, + "Dual Wield": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 7 + } + ] + } + ] + }, + "Ricochet": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 8 + } + ] + } + ] + }, + "Tesla Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 9 + } + ] + } + ] + }, + "Boots": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 10 + } + ] + } + ] + }, + "Speedometer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 11 + } + ] + } + ] + }, + "Shrinker": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 12 + } + ] + } + ] + }, + "Wings": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 13 + } + ] + } + ] + }, + "Feather": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 14 + } + ] + } + ] + }, + "Iron Weights": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 15 + } + ] + } + ] + }, + "Climb Claw": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 16 + } + ] + } + ] + }, + "Recoil Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 17 + } + ] + } + ] + }, + "Diazepam": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 18 + } + ] + } + ] + }, + "Embiggener": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 19 + } + ] + } + ] + }, + "Pebble Helmet": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 20 + } + ] + } + ] + }, + "Shock Absorber": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 21 + } + ] + } + ] + } + } +} diff --git a/resources/gfx/trinkets_small.png b/resources/gfx/trinkets_small.png new file mode 100644 index 0000000..3fd15ab Binary files /dev/null and b/resources/gfx/trinkets_small.png differ diff --git a/resources/gfx/trinkets_tiny.anm2 b/resources/gfx/trinkets_tiny.anm2 new file mode 100644 index 0000000..fc49b79 --- /dev/null +++ b/resources/gfx/trinkets_tiny.anm2 @@ -0,0 +1,567 @@ +{ + "anm2ed": 1, + "info": { + "fps": 60 + }, + "spritesheets": [ + { + "path": "trinkets_tiny.png", + "regions": [ + { + "name": "Gloves", + "crop": [ + 0, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Energy Drink", + "crop": [ + 8, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Accelerator", + "crop": [ + 16, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Enlarger", + "crop": [ + 24, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Boxing Glove", + "crop": [ + 32, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Splitter", + "crop": [ + 40, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Doubler", + "crop": [ + 0, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Dual Wield", + "crop": [ + 8, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Ricochet", + "crop": [ + 16, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Tesla Coil", + "crop": [ + 24, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Boots", + "crop": [ + 32, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Speedometer", + "crop": [ + 40, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Shrinker", + "crop": [ + 0, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Wings", + "crop": [ + 8, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Feather", + "crop": [ + 16, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Iron Weights", + "crop": [ + 24, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Climb Claw", + "crop": [ + 32, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Recoil Coil", + "crop": [ + 40, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Diazepam", + "crop": [ + 0, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Embiggener", + "crop": [ + 8, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Pebble Helmet", + "crop": [ + 16, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Shock Absorber", + "crop": [ + 24, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + } + ] + } + ], + "layers": [ + { + "name": "sprite" + } + ], + "defaultAnimation": "Gloves", + "animations": { + "Gloves": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 0 + } + ] + } + ] + }, + "Energy Drink": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 1 + } + ] + } + ] + }, + "Accelerator": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 2 + } + ] + } + ] + }, + "Enlarger": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 3 + } + ] + } + ] + }, + "Boxing Glove": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 4 + } + ] + } + ] + }, + "Splitter": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 5 + } + ] + } + ] + }, + "Doubler": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 6 + } + ] + } + ] + }, + "Dual Wield": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 7 + } + ] + } + ] + }, + "Ricochet": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 8 + } + ] + } + ] + }, + "Tesla Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 9 + } + ] + } + ] + }, + "Boots": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 10 + } + ] + } + ] + }, + "Speedometer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 11 + } + ] + } + ] + }, + "Shrinker": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 12 + } + ] + } + ] + }, + "Wings": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 13 + } + ] + } + ] + }, + "Feather": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 14 + } + ] + } + ] + }, + "Iron Weights": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 15 + } + ] + } + ] + }, + "Climb Claw": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 16 + } + ] + } + ] + }, + "Recoil Coil": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 17 + } + ] + } + ] + }, + "Diazepam": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 18 + } + ] + } + ] + }, + "Embiggener": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 19 + } + ] + } + ] + }, + "Pebble Helmet": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 20 + } + ] + } + ] + }, + "Shock Absorber": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 21 + } + ] + } + ] + } + } +} diff --git a/resources/gfx/trinkets_tiny.png b/resources/gfx/trinkets_tiny.png new file mode 100644 index 0000000..e12c39c Binary files /dev/null and b/resources/gfx/trinkets_tiny.png differ diff --git a/resources/gfx/ui.anm2 b/resources/gfx/ui.anm2 new file mode 100644 index 0000000..29e3c64 --- /dev/null +++ b/resources/gfx/ui.anm2 @@ -0,0 +1,53 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "ui.png", + "regions": [ + {"name": "Chevron", "crop": [4, 5], "size": [7, 5], "pivot": [7, 5]}, + {"name": "Arrow", "crop": [4, 5], "size": [7, 5], "origin": "center"}, + {"name": "Crosshair", "crop": [17, 1], "size": [13, 13], "pivot": [6, 6]} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Chevron", + "animations": { + "Chevron": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 0} + ] + } + ] + }, + "Arrow": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1} + ] + } + ] + }, + "Crosshair": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 2} + ] + } + ] + } + } +} diff --git a/resources/gfx/ui.png b/resources/gfx/ui.png new file mode 100644 index 0000000..419faa2 Binary files /dev/null and b/resources/gfx/ui.png differ diff --git a/resources/gfx/weapon_icons.anm2 b/resources/gfx/weapon_icons.anm2 new file mode 100644 index 0000000..9152806 --- /dev/null +++ b/resources/gfx/weapon_icons.anm2 @@ -0,0 +1,189 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "weapon_icons.png", + "regions": [ + {"name": "Sword", "crop": [0, 0], "size": [24, 24], "origin": "center"}, + {"name": "Hammer", "crop": [24, 0], "size": [24, 24], "origin": "center"}, + {"name": "Flail", "crop": [48, 0], "size": [24, 24], "origin": "center"}, + {"name": "Lance", "crop": [72, 0], "size": [24, 24], "origin": "center"}, + {"name": "Whip", "crop": [0, 24], "size": [24, 24], "origin": "center"}, + {"name": "Grapple", "crop": [24, 24], "size": [24, 24], "origin": "center"}, + {"name": "Boomerang", "crop": [48, 24], "size": [24, 24], "origin": "center"}, + {"name": "Bow", "crop": [72, 24], "size": [24, 24], "origin": "center"}, + {"name": "Blaster", "crop": [0, 48], "size": [24, 24], "origin": "center"}, + {"name": "Uzi", "crop": [24, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shotgun", "crop": [48, 48], "size": [24, 24], "origin": "center"}, + {"name": "Flamethrower", "crop": [72, 48], "size": [24, 24], "origin": "center"}, + {"name": "Rocket Launcher", "crop": [0, 72], "size": [24, 24], "origin": "center"}, + {"name": "Mine", "crop": [24, 72], "size": [24, 24], "origin": "center"}, + {"name": "Fist", "crop": [48, 72], "size": [24, 24], "origin": "center"}, + {"name": "Grimoire", "crop": [72, 72], "size": [24, 24], "origin": "center"} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Sword", + "animations": { + "Sword": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 0} + ] + } + ] + }, + "Hammer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1} + ] + } + ] + }, + "Flail": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 2} + ] + } + ] + }, + "Lance": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 3} + ] + } + ] + }, + "Whip": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 4} + ] + } + ] + }, + "Grapple": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 5} + ] + } + ] + }, + "Boomerang": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 6} + ] + } + ] + }, + "Bow": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 7} + ] + } + ] + }, + "Blaster": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 8} + ] + } + ] + }, + "Uzi": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 9} + ] + } + ] + }, + "Shotgun": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 10} + ] + } + ] + }, + "Flamethrower": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 11} + ] + } + ] + }, + "Rocket Launcher": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 12} + ] + } + ] + }, + "Mine": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 13} + ] + } + ] + }, + "Fist": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]} + } +} diff --git a/resources/gfx/weapon_icons.png b/resources/gfx/weapon_icons.png new file mode 100644 index 0000000..0951bdd Binary files /dev/null and b/resources/gfx/weapon_icons.png differ diff --git a/resources/gfx/weapon_icons_small.anm2 b/resources/gfx/weapon_icons_small.anm2 new file mode 100644 index 0000000..6f2b36c --- /dev/null +++ b/resources/gfx/weapon_icons_small.anm2 @@ -0,0 +1,419 @@ +{ + "anm2ed": 1, + "info": { + "fps": 60 + }, + "spritesheets": [ + { + "path": "weapon_icons_small.png", + "regions": [ + { + "name": "Sword", + "crop": [ + 0, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Hammer", + "crop": [ + 16, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Flail", + "crop": [ + 32, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Lance", + "crop": [ + 48, + 0 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Whip", + "crop": [ + 0, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Grapple", + "crop": [ + 16, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Boomerang", + "crop": [ + 32, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Bow", + "crop": [ + 48, + 16 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Blaster", + "crop": [ + 0, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Uzi", + "crop": [ + 16, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Shotgun", + "crop": [ + 32, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Flamethrower", + "crop": [ + 48, + 32 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Rocket Launcher", + "crop": [ + 0, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Mine", + "crop": [ + 16, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Fist", + "crop": [ + 32, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + }, + { + "name": "Grimoire", + "crop": [ + 48, + 48 + ], + "size": [ + 16, + 16 + ], + "origin": "center" + } + ] + } + ], + "layers": [ + { + "name": "sprite" + } + ], + "defaultAnimation": "Sword", + "animations": { + "Sword": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 0 + } + ] + } + ] + }, + "Hammer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 1 + } + ] + } + ] + }, + "Flail": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 2 + } + ] + } + ] + }, + "Lance": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 3 + } + ] + } + ] + }, + "Whip": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 4 + } + ] + } + ] + }, + "Grapple": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 5 + } + ] + } + ] + }, + "Boomerang": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 6 + } + ] + } + ] + }, + "Bow": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 7 + } + ] + } + ] + }, + "Blaster": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 8 + } + ] + } + ] + }, + "Uzi": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 9 + } + ] + } + ] + }, + "Shotgun": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 10 + } + ] + } + ] + }, + "Flamethrower": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 11 + } + ] + } + ] + }, + "Rocket Launcher": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 12 + } + ] + } + ] + }, + "Mine": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 13 + } + ] + } + ] + }, + "Fist": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 14 + } + ] + } + ] + }, + "Grimoire": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 15 + } + ] + } + ] + } + } +} diff --git a/resources/gfx/weapon_icons_small.png b/resources/gfx/weapon_icons_small.png new file mode 100644 index 0000000..ad86b56 Binary files /dev/null and b/resources/gfx/weapon_icons_small.png differ diff --git a/resources/gfx/weapon_icons_tiny.anm2 b/resources/gfx/weapon_icons_tiny.anm2 new file mode 100644 index 0000000..e986a8b --- /dev/null +++ b/resources/gfx/weapon_icons_tiny.anm2 @@ -0,0 +1,419 @@ +{ + "anm2ed": 1, + "info": { + "fps": 60 + }, + "spritesheets": [ + { + "path": "weapon_icons_tiny.png", + "regions": [ + { + "name": "Sword", + "crop": [ + 0, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Hammer", + "crop": [ + 8, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Flail", + "crop": [ + 16, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Lance", + "crop": [ + 24, + 0 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Whip", + "crop": [ + 0, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Grapple", + "crop": [ + 8, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Boomerang", + "crop": [ + 16, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Bow", + "crop": [ + 24, + 8 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Blaster", + "crop": [ + 0, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Uzi", + "crop": [ + 8, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Shotgun", + "crop": [ + 16, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Flamethrower", + "crop": [ + 24, + 16 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Rocket Launcher", + "crop": [ + 0, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Mine", + "crop": [ + 8, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Fist", + "crop": [ + 16, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + }, + { + "name": "Grimoire", + "crop": [ + 24, + 24 + ], + "size": [ + 8, + 8 + ], + "origin": "center" + } + ] + } + ], + "layers": [ + { + "name": "sprite" + } + ], + "defaultAnimation": "Sword", + "animations": { + "Sword": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 0 + } + ] + } + ] + }, + "Hammer": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 1 + } + ] + } + ] + }, + "Flail": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 2 + } + ] + } + ] + }, + "Lance": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 3 + } + ] + } + ] + }, + "Whip": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 4 + } + ] + } + ] + }, + "Grapple": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 5 + } + ] + } + ] + }, + "Boomerang": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 6 + } + ] + } + ] + }, + "Bow": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 7 + } + ] + } + ] + }, + "Blaster": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 8 + } + ] + } + ] + }, + "Uzi": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 9 + } + ] + } + ] + }, + "Shotgun": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 10 + } + ] + } + ] + }, + "Flamethrower": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 11 + } + ] + } + ] + }, + "Rocket Launcher": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 12 + } + ] + } + ] + }, + "Mine": { + "length": 1, + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 13 + } + ] + } + ] + }, + "Fist": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 14 + } + ] + } + ] + }, + "Grimoire": { + "layers": [ + { + "layer": 0, + "frames": [ + { + "region": 15 + } + ] + } + ] + } + } +} diff --git a/resources/gfx/weapon_icons_tiny.png b/resources/gfx/weapon_icons_tiny.png new file mode 100644 index 0000000..34fdc04 Binary files /dev/null and b/resources/gfx/weapon_icons_tiny.png differ diff --git a/resources/gfx/weapons.anm2 b/resources/gfx/weapons.anm2 new file mode 100644 index 0000000..65c1ca0 --- /dev/null +++ b/resources/gfx/weapons.anm2 @@ -0,0 +1,55 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "weapons.png", + "regions": [ + {"name": "Sword", "crop": [8, 10], "size": [32, 12], "pivot": [4, 5.5]}, + {"name": "Hammer", "crop": [57, 9], "size": [29, 14], "pivot": [4, 7]}, + {"name": "Flail", "crop": [107, 5], "size": [30, 19], "pivot": [3, 9]}, + {"name": "Lance", "crop": [145, 10], "size": [47, 12], "pivot": [6, 6]}, + {"name": "Whip", "crop": [11, 41], "size": [24, 22], "pivot": [7, 3]}, + {"name": "Whip Base", "crop": [154, 105], "size": [15, 12], "pivot": [8, 3]}, + {"name": "Grapple", "crop": [60, 41], "size": [27, 13], "pivot": [3, 6.5]}, + {"name": "Boomerang", "crop": [111, 41], "size": [19, 13], "pivot": [9, 2]}, + {"name": "Bow", "crop": [161, 35], "size": [14, 27], "pivot": [2, 13]}, + {"name": "Blaster", "crop": [11, 74], "size": [23, 14], "pivot": [4, 9]}, + {"name": "Uzi", "crop": [61, 72], "size": [24, 18], "pivot": [10, 8]}, + {"name": "Shotgun", "crop": [104, 74], "size": [31, 10], "pivot": [8, 5]}, + {"name": "Flamethrower", "crop": [151, 72], "size": [31, 17], "pivot": [5, 9]}, + {"name": "Rocket Launcher", "crop": [8, 106], "size": [31, 12], "pivot": [10, 5]}, + {"name": "Mine", "crop": [66, 103], "size": [10, 18], "pivot": [1, 11.5]}, + {"name": "Grapple Tip", "crop": [70, 41], "size": [17, 13], "pivot": [0, 6.5]}, + {"name": "Fist", "crop": [111, 105], "size": [14, 14], "pivot": [0, 7]}, + {"name": "Grapple Base", "crop": [60, 41], "size": [10, 13], "pivot": [3, 6.5]}, + {"name": "Grimoire", "crop": [10, 134], "size": [16, 9], "pivot": [8, 2.5]} + ] + } + ], + "layers": [ + {"name": "sprite"} + ], + "defaultAnimation": "Sword", + "animations": { + "Sword": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, + "Hammer": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, + "Flail": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, + "Lance": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, + "Whip": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, + "Whip Base": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, + "Grapple": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, + "Grapple Tip": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, + "Grapple Base": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, + "Boomerang": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, + "Bow": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Mine": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Fist": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, + "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]} + } +} diff --git a/resources/gfx/weapons.png b/resources/gfx/weapons.png new file mode 100644 index 0000000..6a26326 Binary files /dev/null and b/resources/gfx/weapons.png differ diff --git a/resources/sfx/SOURCES.md b/resources/sfx/SOURCES.md new file mode 100644 index 0000000..52b80ce --- /dev/null +++ b/resources/sfx/SOURCES.md @@ -0,0 +1,105 @@ +# Sound sources + +Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studios one (or synthesized), resampled with no filtering to 11025 Hz unless noted, stored as SNES-style BRR (4-bit ADPCM, 16 samples in 9 bytes) and played back with no interpolation. Built by tools/sfx_build.py. + +| Sound | Source | From | Speed | Rate | BRR bytes | +|---|---|---|---|---|---| +| beam | SpaceZap 4001_88_7 | 1.09s | 1x | 11025 | 2727 | +| blast | synth pew | | 1x | 11025 | 1053 | +| boomerang | CartoonWhizz 4001_75_4 | 1.00s | 1x | 11025 | 1791 | +| boomerang_heavy | CartoonWhizz 4001_75_8 | 1.33s | 1x | 11025 | 3204 | +| bounce | BodyfallHuman 4003_59_1 | 0.54s | 1x | 11025 | 963 | +| bow | US02-44-1 ARCHERY - SINGLE BOW TWANG | 0.17s | 1x | 11025 | 972 | +| bow_heavy | US02-44-1 ARCHERY - SINGLE BOW TWANG | 0.17s | 0.8x | 11025 | 1233 | +| break | US02-96-1 CRASH, MISCELLANEOUS - SMALL OBJECTS FALLING ON CEMENT | 0.59s | 1x | 11025 | 2808 | +| cast | synth ember | | 1x | 11025 | 2556 | +| charge | synth warp | | 1x | 11025 | 5076 | +| chat | synth beeps | | 1x | 11025 | 567 | +| crawl | kept from the synth set | | | | | +| dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 | +| death | US03-34-1 KICK, HUMAN - SINGLE KICK INTO STOMACH | 0.17s | 1x | 11025 | 2916 | +| death | CartoonSquish 4001_30_4 | 0.80s | 1x | 11025 | 2916 | +| detonate | kept from the synth set | | | | | +| detonate_empty | HandGunFoley GUNS03_24_3 | 0.09s | 1x | 16000 | 891 | +| duck | kept from the synth set | | | | | +| echo | synth shimmer | | 1x | 11025 | 1665 | +| equip | MachineGunFoley GUNS03_39_1 | 0.03s | 1x | 11025 | 1980 | +| explosion | Explosion 4003_97_2 | 2.21s | 1x | 8000 | 4158 | +| fall | CartoonWhistle 4001_78_3 | 1.43s | 1x | 11025 | 5076 | +| finish | kept from the demo1 set | | | | | +| fireball | kept from the demo1 set | | | | | +| flail | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1980 | +| flail | ChainRattle 4003_84 | 15.46s | 1x | 11025 | 1980 | +| flail_heavy | Swish 4003_47_1 | 0.40s | 0.7x | 11025 | 2916 | +| flail_heavy | ChainRattle 4003_84 | 15.46s | 1x | 11025 | 2916 | +| flail_land | US03-37-2 METAL, BANG - LARGE METAL WRENCH BOUNCES OFF STEEL AND FALLS | 0.78s | 1x | 11025 | 1872 | +| flame | US03-17-2 FIRE, FLASH - WIZARD FIRE PUFF, FLASH POT | 0.68s | 1.4x | 11025 | 864 | +| grapple | US04-42-1 GUN, SILENCER - SUPPRESSED FIRING - SINGLE SHOT | 0.16s | 1x | 11025 | 1665 | +| grapple | ChainRattle 4003_84 | 15.46s | 1.3x | 11025 | 1665 | +| grapple_heavy | Swish 4003_47_1 | 0.40s | 0.8x | 11025 | 2106 | +| grapple_heavy | ChainRattle 4003_84 | 11.94s | 1x | 11025 | 2106 | +| hit_blunt | PunchBody 4003_49_1 | 0.41s | 1x | 11025 | 1044 | +| hit_blunt_heavy | PunchBody 4003_48_6 | 0.84s | 1x | 11025 | 1476 | +| hit_bullet | BulletImpact GUNS03_67_7 | 0.33s | 1x | 16000 | 684 | +| hit_bullet_heavy | BulletImpact GUNS03_65_12 | 0.15s | 1x | 11025 | 1791 | +| hit_energy | CartoonGun 4002_27_5 | 0.81s | 1x | 11025 | 972 | +| hit_energy_heavy | SpaceZap 4001_88_8 | 0.83s | 1x | 11025 | 1872 | +| hit_explosive | Explosion 4003_92_4 | 2.44s | 1x | 11025 | 1791 | +| hit_explosive_heavy | Explosion 4003_97_5 | 1.21s | 1x | 8000 | 2331 | +| hit_fire | Explosion 4003_94_4 | 3.14s | 1x | 11025 | 1980 | +| hit_fire_heavy | GrenadeHand GUNS02_46_1 | 0.85s | 1x | 8000 | 2151 | +| hit_lash | SlapFace 4003_52_5 | 1.21s | 1x | 11025 | 648 | +| hit_lash_heavy | SlapFace 4003_52_6 | 1.16s | 1x | 11025 | 1332 | +| hit_magic | US03-17-1 FIRE, FLASH - WIZARD FIRE PUFF, FLASH POT | 0.17s | 1.3x | 16000 | 1359 | +| hit_magic | synth sparkle | | 1x | 16000 | 1359 | +| hit_magic_heavy | SpaceZap 4001_88_9 | 1.03s | 1x | 16000 | 2718 | +| hit_pierce | BulletImpact GUNS03_64_1 | 0.04s | 1x | 11025 | 828 | +| hit_pierce_heavy | Bayonnet GUNS02_24_3 | 0.12s | 1x | 11025 | 1692 | +| hit_slash | StabHuman 4003_56_4 | 0.87s | 1x | 11025 | 900 | +| hit_slash_heavy | Bayonnet GUNS02_23_8 | 0.14s | 1x | 11025 | 1791 | +| impact_arrow | DropWood 4003_63_3 | 1.53s | 1x | 11025 | 1332 | +| impact_blaster | BulletImpact GUNS03_65_8 | 0.02s | 1x | 11025 | 792 | +| impact_boomerang | CartoonBonk 4001_03_4 | 0.78s | 1x | 11025 | 1053 | +| impact_bullet | BulletImpact GUNS03_64_1 | 0.04s | 1x | 16000 | 738 | +| impact_mine | DropCan 4003_62_5 | 1.27s | 1x | 11025 | 864 | +| jump | kept from the synth set | | | | | +| land | kept from the synth set | | | | | +| latch | US02-21-3 AMUSEMENT PARK, GAME - BALL GAME - HIT METAL TARGET | 0.16s | 1x | 11025 | 1584 | +| level_down | synth arp | | 1x | 11025 | 2268 | +| level_up | synth arp | | 1x | 11025 | 2268 | +| mine | Swish 4003_47_3 | 0.18s | 1x | 11025 | 1512 | +| mine | synth beeps | | 1x | 11025 | 1512 | +| poke | Bayonnet GUNS02_23_6 | 0.10s | 1x | 11025 | 864 | +| poke_heavy | Bayonnet GUNS02_24_1 | 0.14s | 1x | 11025 | 2106 | +| pound | synth thud | | 1x | 8000 | 2331 | +| punch | Swish 4003_47_1 | 0.40s | 1x | 11025 | 648 | +| ready | US03-02-1 DISHES, GLASS, TOAST - TWO GLASS BEER MUGS CLINK TOGETHER | 0.16s | 1x | 11025 | 1080 | +| ricochet | BulletRicochet GUNS03_77_14 | 9.39s | 1x | 16000 | 2601 | +| rocket | RocketLauncher GUNS02_99_1 | 0.02s | 1x | 11025 | 2727 | +| rocket_heavy | GrenadeLauncher GUNS02_49_5 | 19.68s | 1x | 8000 | 3555 | +| roll | kept from the synth set | | | | | +| select | DropCoin 4003_64_4 | 1.65s | 1x | 16000 | 621 | +| shotgun | Shotgun GUNS02_18_2 | 0.01s | 1x | 11025 | 4572 | +| shotgun | ShotgunFoley GUNS03_57_6 | 0.06s | 1x | 11025 | 4572 | +| shotgun_heavy | Shotgun GUNS02_19_2 | 0.01s | 1x | 11025 | 8190 | +| shotgun_heavy | ShotgunFoley GUNS03_57_2 | 0.06s | 0.8x | 11025 | 8190 | +| slam | US03-39-2 METAL, HIT - LOUD METAL CLUNK | 0.16s | 0.7x | 11025 | 3852 | +| slash | Swish 4003_47_2 | 0.79s | 1x | 11025 | 1170 | +| slash_heavy | Swish 4003_47_2 | 0.79s | 0.75x | 11025 | 1908 | +| step | CartoonFootsteps 4003_46_2 | 0.80s | 1x | 16000 | 891 | +| swap | kept from the demo1 set | | | | | +| swing | Swish 4003_47_1 | 0.40s | 1x | 11025 | 1098 | +| swing_heavy | Swish 4003_47_1 | 0.40s | 0.7x | 11025 | 1764 | +| teleport | US03-68-1 SPACE - DESCENDING SPACE WHINES | 0.80s | 1x | 11025 | 1872 | +| teleport_warp | US03-68-3 SPACE - ASCENDING UNDULATING SPACE WHINE | 0.32s | 3x | 11025 | 2376 | +| unduck | kept from the synth set | | | | | +| unequip | RifleFoley GUNS03_55_1 | 0.49s | 1x | 11025 | 1548 | +| uppercut | synth whoosh | | 1x | 11025 | 3168 | +| uzi | kept from the demo1 set | | | | | +| uzi_heavy | GunMachineGun 4002_86_3 | 1.83s | 1x | 11025 | 1872 | +| whip | Whip 4003_57_4 | 2.04s | 1x | 11025 | 1692 | +| whip_heavy | kept from the demo1 (its whip) set | | | | | +| wisp | US02-45-5 ARCHERY - SHOOT FLAMING ARROW | 0.18s | 1x | 11025 | 1584 | +| zap | US03-12-1 ELECTRICITY - SMALL TRANSFORMER ARCS | 0.48s | 1x | 11025 | 1296 | + +The built sounds come to 144 KB as BRR. diff --git a/resources/sfx/beam.ogg b/resources/sfx/beam.ogg new file mode 100644 index 0000000..234d0df Binary files /dev/null and b/resources/sfx/beam.ogg differ diff --git a/resources/sfx/blast.ogg b/resources/sfx/blast.ogg new file mode 100644 index 0000000..c656f5d Binary files /dev/null and b/resources/sfx/blast.ogg differ diff --git a/resources/sfx/boomerang.ogg b/resources/sfx/boomerang.ogg new file mode 100644 index 0000000..e81f1bf Binary files /dev/null and b/resources/sfx/boomerang.ogg differ diff --git a/resources/sfx/boomerang_heavy.ogg b/resources/sfx/boomerang_heavy.ogg new file mode 100644 index 0000000..febed5b Binary files /dev/null and b/resources/sfx/boomerang_heavy.ogg differ diff --git a/resources/sfx/bounce.ogg b/resources/sfx/bounce.ogg new file mode 100644 index 0000000..151f582 Binary files /dev/null and b/resources/sfx/bounce.ogg differ diff --git a/resources/sfx/bow.ogg b/resources/sfx/bow.ogg new file mode 100644 index 0000000..e5189d5 Binary files /dev/null and b/resources/sfx/bow.ogg differ diff --git a/resources/sfx/bow_heavy.ogg b/resources/sfx/bow_heavy.ogg new file mode 100644 index 0000000..abece51 Binary files /dev/null and b/resources/sfx/bow_heavy.ogg differ diff --git a/resources/sfx/break.ogg b/resources/sfx/break.ogg new file mode 100644 index 0000000..074fa3d Binary files /dev/null and b/resources/sfx/break.ogg differ diff --git a/resources/sfx/cast.ogg b/resources/sfx/cast.ogg new file mode 100644 index 0000000..7431855 Binary files /dev/null and b/resources/sfx/cast.ogg differ diff --git a/resources/sfx/charge.ogg b/resources/sfx/charge.ogg new file mode 100644 index 0000000..fb22fd6 Binary files /dev/null and b/resources/sfx/charge.ogg differ diff --git a/resources/sfx/chat.ogg b/resources/sfx/chat.ogg new file mode 100644 index 0000000..042e77e Binary files /dev/null and b/resources/sfx/chat.ogg differ diff --git a/resources/sfx/crawl.ogg b/resources/sfx/crawl.ogg new file mode 100644 index 0000000..3e31aeb Binary files /dev/null and b/resources/sfx/crawl.ogg differ diff --git a/resources/sfx/dash.ogg b/resources/sfx/dash.ogg new file mode 100644 index 0000000..50fe2b4 Binary files /dev/null and b/resources/sfx/dash.ogg differ diff --git a/resources/sfx/death.ogg b/resources/sfx/death.ogg new file mode 100644 index 0000000..fce2777 Binary files /dev/null and b/resources/sfx/death.ogg differ diff --git a/resources/sfx/detonate.ogg b/resources/sfx/detonate.ogg new file mode 100644 index 0000000..39446d7 Binary files /dev/null and b/resources/sfx/detonate.ogg differ diff --git a/resources/sfx/detonate_empty.ogg b/resources/sfx/detonate_empty.ogg new file mode 100644 index 0000000..777311e Binary files /dev/null and b/resources/sfx/detonate_empty.ogg differ diff --git a/resources/sfx/duck.ogg b/resources/sfx/duck.ogg new file mode 100644 index 0000000..57f855f Binary files /dev/null and b/resources/sfx/duck.ogg differ diff --git a/resources/sfx/echo.ogg b/resources/sfx/echo.ogg new file mode 100644 index 0000000..b6e265e Binary files /dev/null and b/resources/sfx/echo.ogg differ diff --git a/resources/sfx/equip.ogg b/resources/sfx/equip.ogg new file mode 100644 index 0000000..9bb4624 Binary files /dev/null and b/resources/sfx/equip.ogg differ diff --git a/resources/sfx/explosion.ogg b/resources/sfx/explosion.ogg new file mode 100644 index 0000000..b4eaea4 Binary files /dev/null and b/resources/sfx/explosion.ogg differ diff --git a/resources/sfx/fall.ogg b/resources/sfx/fall.ogg new file mode 100644 index 0000000..b7e4aa0 Binary files /dev/null and b/resources/sfx/fall.ogg differ diff --git a/resources/sfx/finish.ogg b/resources/sfx/finish.ogg new file mode 100644 index 0000000..b50f90c Binary files /dev/null and b/resources/sfx/finish.ogg differ diff --git a/resources/sfx/fireball.ogg b/resources/sfx/fireball.ogg new file mode 100644 index 0000000..ec52afd Binary files /dev/null and b/resources/sfx/fireball.ogg differ diff --git a/resources/sfx/flail.ogg b/resources/sfx/flail.ogg new file mode 100644 index 0000000..abc0c21 Binary files /dev/null and b/resources/sfx/flail.ogg differ diff --git a/resources/sfx/flail_heavy.ogg b/resources/sfx/flail_heavy.ogg new file mode 100644 index 0000000..48af716 Binary files /dev/null and b/resources/sfx/flail_heavy.ogg differ diff --git a/resources/sfx/flail_land.ogg b/resources/sfx/flail_land.ogg new file mode 100644 index 0000000..7a51b4d Binary files /dev/null and b/resources/sfx/flail_land.ogg differ diff --git a/resources/sfx/flame.ogg b/resources/sfx/flame.ogg new file mode 100644 index 0000000..73b125d Binary files /dev/null and b/resources/sfx/flame.ogg differ diff --git a/resources/sfx/grapple.ogg b/resources/sfx/grapple.ogg new file mode 100644 index 0000000..7add34f Binary files /dev/null and b/resources/sfx/grapple.ogg differ diff --git a/resources/sfx/grapple_heavy.ogg b/resources/sfx/grapple_heavy.ogg new file mode 100644 index 0000000..f7d0c15 Binary files /dev/null and b/resources/sfx/grapple_heavy.ogg differ diff --git a/resources/sfx/hit_blunt.ogg b/resources/sfx/hit_blunt.ogg new file mode 100644 index 0000000..e0c5027 Binary files /dev/null and b/resources/sfx/hit_blunt.ogg differ diff --git a/resources/sfx/hit_blunt_heavy.ogg b/resources/sfx/hit_blunt_heavy.ogg new file mode 100644 index 0000000..ec624b8 Binary files /dev/null and b/resources/sfx/hit_blunt_heavy.ogg differ diff --git a/resources/sfx/hit_bullet.ogg b/resources/sfx/hit_bullet.ogg new file mode 100644 index 0000000..e9ebdb2 Binary files /dev/null and b/resources/sfx/hit_bullet.ogg differ diff --git a/resources/sfx/hit_bullet_heavy.ogg b/resources/sfx/hit_bullet_heavy.ogg new file mode 100644 index 0000000..1e4c280 Binary files /dev/null and b/resources/sfx/hit_bullet_heavy.ogg differ diff --git a/resources/sfx/hit_energy.ogg b/resources/sfx/hit_energy.ogg new file mode 100644 index 0000000..cef0f6b Binary files /dev/null and b/resources/sfx/hit_energy.ogg differ diff --git a/resources/sfx/hit_energy_heavy.ogg b/resources/sfx/hit_energy_heavy.ogg new file mode 100644 index 0000000..1c7a112 Binary files /dev/null and b/resources/sfx/hit_energy_heavy.ogg differ diff --git a/resources/sfx/hit_explosive.ogg b/resources/sfx/hit_explosive.ogg new file mode 100644 index 0000000..d87ce37 Binary files /dev/null and b/resources/sfx/hit_explosive.ogg differ diff --git a/resources/sfx/hit_explosive_heavy.ogg b/resources/sfx/hit_explosive_heavy.ogg new file mode 100644 index 0000000..54cbd49 Binary files /dev/null and b/resources/sfx/hit_explosive_heavy.ogg differ diff --git a/resources/sfx/hit_fire.ogg b/resources/sfx/hit_fire.ogg new file mode 100644 index 0000000..1e1fd0a Binary files /dev/null and b/resources/sfx/hit_fire.ogg differ diff --git a/resources/sfx/hit_fire_heavy.ogg b/resources/sfx/hit_fire_heavy.ogg new file mode 100644 index 0000000..ca10700 Binary files /dev/null and b/resources/sfx/hit_fire_heavy.ogg differ diff --git a/resources/sfx/hit_lash.ogg b/resources/sfx/hit_lash.ogg new file mode 100644 index 0000000..aa2e634 Binary files /dev/null and b/resources/sfx/hit_lash.ogg differ diff --git a/resources/sfx/hit_lash_heavy.ogg b/resources/sfx/hit_lash_heavy.ogg new file mode 100644 index 0000000..8cc7121 Binary files /dev/null and b/resources/sfx/hit_lash_heavy.ogg differ diff --git a/resources/sfx/hit_magic.ogg b/resources/sfx/hit_magic.ogg new file mode 100644 index 0000000..b69e27e Binary files /dev/null and b/resources/sfx/hit_magic.ogg differ diff --git a/resources/sfx/hit_magic_heavy.ogg b/resources/sfx/hit_magic_heavy.ogg new file mode 100644 index 0000000..0680ef5 Binary files /dev/null and b/resources/sfx/hit_magic_heavy.ogg differ diff --git a/resources/sfx/hit_pierce.ogg b/resources/sfx/hit_pierce.ogg new file mode 100644 index 0000000..02dbc25 Binary files /dev/null and b/resources/sfx/hit_pierce.ogg differ diff --git a/resources/sfx/hit_pierce_heavy.ogg b/resources/sfx/hit_pierce_heavy.ogg new file mode 100644 index 0000000..9eeeab5 Binary files /dev/null and b/resources/sfx/hit_pierce_heavy.ogg differ diff --git a/resources/sfx/hit_slash.ogg b/resources/sfx/hit_slash.ogg new file mode 100644 index 0000000..560ad69 Binary files /dev/null and b/resources/sfx/hit_slash.ogg differ diff --git a/resources/sfx/hit_slash_heavy.ogg b/resources/sfx/hit_slash_heavy.ogg new file mode 100644 index 0000000..7f4ad3a Binary files /dev/null and b/resources/sfx/hit_slash_heavy.ogg differ diff --git a/resources/sfx/impact_arrow.ogg b/resources/sfx/impact_arrow.ogg new file mode 100644 index 0000000..03908bb Binary files /dev/null and b/resources/sfx/impact_arrow.ogg differ diff --git a/resources/sfx/impact_blaster.ogg b/resources/sfx/impact_blaster.ogg new file mode 100644 index 0000000..b98a21e Binary files /dev/null and b/resources/sfx/impact_blaster.ogg differ diff --git a/resources/sfx/impact_boomerang.ogg b/resources/sfx/impact_boomerang.ogg new file mode 100644 index 0000000..79bfe33 Binary files /dev/null and b/resources/sfx/impact_boomerang.ogg differ diff --git a/resources/sfx/impact_bullet.ogg b/resources/sfx/impact_bullet.ogg new file mode 100644 index 0000000..db4b316 Binary files /dev/null and b/resources/sfx/impact_bullet.ogg differ diff --git a/resources/sfx/impact_mine.ogg b/resources/sfx/impact_mine.ogg new file mode 100644 index 0000000..5b484a5 Binary files /dev/null and b/resources/sfx/impact_mine.ogg differ diff --git a/resources/sfx/jump.ogg b/resources/sfx/jump.ogg new file mode 100644 index 0000000..8c7015e Binary files /dev/null and b/resources/sfx/jump.ogg differ diff --git a/resources/sfx/land.ogg b/resources/sfx/land.ogg new file mode 100644 index 0000000..766673c Binary files /dev/null and b/resources/sfx/land.ogg differ diff --git a/resources/sfx/latch.ogg b/resources/sfx/latch.ogg new file mode 100644 index 0000000..d0e39da Binary files /dev/null and b/resources/sfx/latch.ogg differ diff --git a/resources/sfx/level_down.ogg b/resources/sfx/level_down.ogg new file mode 100644 index 0000000..c1c5de0 Binary files /dev/null and b/resources/sfx/level_down.ogg differ diff --git a/resources/sfx/level_up.ogg b/resources/sfx/level_up.ogg new file mode 100644 index 0000000..c531736 Binary files /dev/null and b/resources/sfx/level_up.ogg differ diff --git a/resources/sfx/mine.ogg b/resources/sfx/mine.ogg new file mode 100644 index 0000000..4f6cbe6 Binary files /dev/null and b/resources/sfx/mine.ogg differ diff --git a/resources/sfx/poke.ogg b/resources/sfx/poke.ogg new file mode 100644 index 0000000..3b5c0d1 Binary files /dev/null and b/resources/sfx/poke.ogg differ diff --git a/resources/sfx/poke_heavy.ogg b/resources/sfx/poke_heavy.ogg new file mode 100644 index 0000000..6a83ee7 Binary files /dev/null and b/resources/sfx/poke_heavy.ogg differ diff --git a/resources/sfx/pound.ogg b/resources/sfx/pound.ogg new file mode 100644 index 0000000..b9f986d Binary files /dev/null and b/resources/sfx/pound.ogg differ diff --git a/resources/sfx/punch.ogg b/resources/sfx/punch.ogg new file mode 100644 index 0000000..61a23e4 Binary files /dev/null and b/resources/sfx/punch.ogg differ diff --git a/resources/sfx/ready.ogg b/resources/sfx/ready.ogg new file mode 100644 index 0000000..b4a65f0 Binary files /dev/null and b/resources/sfx/ready.ogg differ diff --git a/resources/sfx/ricochet.ogg b/resources/sfx/ricochet.ogg new file mode 100644 index 0000000..c52c1ae Binary files /dev/null and b/resources/sfx/ricochet.ogg differ diff --git a/resources/sfx/rocket.ogg b/resources/sfx/rocket.ogg new file mode 100644 index 0000000..6eeb143 Binary files /dev/null and b/resources/sfx/rocket.ogg differ diff --git a/resources/sfx/rocket_heavy.ogg b/resources/sfx/rocket_heavy.ogg new file mode 100644 index 0000000..2f1a9c3 Binary files /dev/null and b/resources/sfx/rocket_heavy.ogg differ diff --git a/resources/sfx/roll.ogg b/resources/sfx/roll.ogg new file mode 100644 index 0000000..5eaf6d0 Binary files /dev/null and b/resources/sfx/roll.ogg differ diff --git a/resources/sfx/select.ogg b/resources/sfx/select.ogg new file mode 100644 index 0000000..4b0704d Binary files /dev/null and b/resources/sfx/select.ogg differ diff --git a/resources/sfx/shotgun.ogg b/resources/sfx/shotgun.ogg new file mode 100644 index 0000000..3522d9e Binary files /dev/null and b/resources/sfx/shotgun.ogg differ diff --git a/resources/sfx/shotgun_heavy.ogg b/resources/sfx/shotgun_heavy.ogg new file mode 100644 index 0000000..3b7d577 Binary files /dev/null and b/resources/sfx/shotgun_heavy.ogg differ diff --git a/resources/sfx/slam.ogg b/resources/sfx/slam.ogg new file mode 100644 index 0000000..ab281ec Binary files /dev/null and b/resources/sfx/slam.ogg differ diff --git a/resources/sfx/slash.ogg b/resources/sfx/slash.ogg new file mode 100644 index 0000000..95fbf26 Binary files /dev/null and b/resources/sfx/slash.ogg differ diff --git a/resources/sfx/slash_heavy.ogg b/resources/sfx/slash_heavy.ogg new file mode 100644 index 0000000..29d8d5d Binary files /dev/null and b/resources/sfx/slash_heavy.ogg differ diff --git a/resources/sfx/step.ogg b/resources/sfx/step.ogg new file mode 100644 index 0000000..4b22f93 Binary files /dev/null and b/resources/sfx/step.ogg differ diff --git a/resources/sfx/swap.ogg b/resources/sfx/swap.ogg new file mode 100644 index 0000000..2ff2858 Binary files /dev/null and b/resources/sfx/swap.ogg differ diff --git a/resources/sfx/swing.ogg b/resources/sfx/swing.ogg new file mode 100644 index 0000000..8bdf622 Binary files /dev/null and b/resources/sfx/swing.ogg differ diff --git a/resources/sfx/swing_heavy.ogg b/resources/sfx/swing_heavy.ogg new file mode 100644 index 0000000..5621059 Binary files /dev/null and b/resources/sfx/swing_heavy.ogg differ diff --git a/resources/sfx/teleport.ogg b/resources/sfx/teleport.ogg new file mode 100644 index 0000000..4beb3e9 Binary files /dev/null and b/resources/sfx/teleport.ogg differ diff --git a/resources/sfx/teleport_warp.ogg b/resources/sfx/teleport_warp.ogg new file mode 100644 index 0000000..59ac8d6 Binary files /dev/null and b/resources/sfx/teleport_warp.ogg differ diff --git a/resources/sfx/unduck.ogg b/resources/sfx/unduck.ogg new file mode 100644 index 0000000..3972afd Binary files /dev/null and b/resources/sfx/unduck.ogg differ diff --git a/resources/sfx/unequip.ogg b/resources/sfx/unequip.ogg new file mode 100644 index 0000000..33110af Binary files /dev/null and b/resources/sfx/unequip.ogg differ diff --git a/resources/sfx/uppercut.ogg b/resources/sfx/uppercut.ogg new file mode 100644 index 0000000..cab798b Binary files /dev/null and b/resources/sfx/uppercut.ogg differ diff --git a/resources/sfx/uzi.ogg b/resources/sfx/uzi.ogg new file mode 100644 index 0000000..839bb10 Binary files /dev/null and b/resources/sfx/uzi.ogg differ diff --git a/resources/sfx/uzi_heavy.ogg b/resources/sfx/uzi_heavy.ogg new file mode 100644 index 0000000..243404f Binary files /dev/null and b/resources/sfx/uzi_heavy.ogg differ diff --git a/resources/sfx/whip.ogg b/resources/sfx/whip.ogg new file mode 100644 index 0000000..f5df04c Binary files /dev/null and b/resources/sfx/whip.ogg differ diff --git a/resources/sfx/whip_heavy.ogg b/resources/sfx/whip_heavy.ogg new file mode 100644 index 0000000..e37b9be Binary files /dev/null and b/resources/sfx/whip_heavy.ogg differ diff --git a/resources/sfx/wisp.ogg b/resources/sfx/wisp.ogg new file mode 100644 index 0000000..6d1e6ef Binary files /dev/null and b/resources/sfx/wisp.ogg differ diff --git a/resources/sfx/zap.ogg b/resources/sfx/zap.ogg new file mode 100644 index 0000000..8a2ebf2 Binary files /dev/null and b/resources/sfx/zap.ogg differ diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt new file mode 100644 index 0000000..e4e1956 --- /dev/null +++ b/server/CMakeLists.txt @@ -0,0 +1,25 @@ +# The server. Built as part of the game's desktop build, or on its own (for the VPS, without SDL): +# cmake -S server -B build-server -DCMAKE_BUILD_TYPE=Release && cmake --build build-server +cmake_minimum_required(VERSION 3.24) +project(smack_a_brother_server LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(EXTERNAL ${CMAKE_CURRENT_SOURCE_DIR}/../external) + +if(NOT TARGET datachannel-static) + set(NO_MEDIA ON CACHE BOOL "" FORCE) + set(NO_EXAMPLES ON CACHE BOOL "" FORCE) + set(NO_TESTS ON CACHE BOOL "" FORCE) + add_subdirectory(${EXTERNAL}/libdatachannel ${CMAKE_BINARY_DIR}/libdatachannel EXCLUDE_FROM_ALL) +endif() +if(NOT TARGET simdjson) + add_library(simdjson STATIC ${EXTERNAL}/simdjson/singleheader/simdjson.cpp) + target_include_directories(simdjson SYSTEM PUBLIC ${EXTERNAL}/simdjson/singleheader) + target_compile_definitions(simdjson PUBLIC SIMDJSON_EXCEPTIONS=0) +endif() + +add_executable(smack_a_brother_server server.cpp) +target_include_directories(smack_a_brother_server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) +target_link_libraries(smack_a_brother_server PRIVATE LibDataChannel::LibDataChannelStatic simdjson) +set_target_properties(smack_a_brother_server PROPERTIES OUTPUT_NAME smack-a-brother-server) diff --git a/server/server.cpp b/server/server.cpp new file mode 100644 index 0000000..1bd61b8 --- /dev/null +++ b/server/server.cpp @@ -0,0 +1,240 @@ +// The server for Smack a Brother. Players connect over a WebSocket (behind nginx, which handles TLS), make a +// lobby or join one by its code, and the server passes WebRTC signaling between a lobby's members so they can connect +// to each other directly. Nothing of a match passes through here. See src/protocol.h for the messages. +// +// smack-a-brother-server [port] (listens on 127.0.0.1, port 47800 by default) + +#include "protocol.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr uint16_t PORT_DEFAULT = 47800; +constexpr size_t MESSAGE_MAX = 16384; +constexpr int CODE_TRIES = 100; + +// Someone connected: their id, name, socket, and the lobby they're in (empty until they make or join one). +struct Member +{ + std::string name; + std::shared_ptr socket; + std::string code; +}; + +// Every member and lobby (codes to member ids, in the order they joined, the first hosting). The WebSocket callbacks +// run on the library's threads, so everything here is touched under the lock. +static std::mutex lock; +static std::map members; +static std::map> lobbies; +static simdjson::dom::parser parser; +static std::mt19937 rng(std::random_device{}()); +static int nextId = 1; + +static void send(int id, const std::string& message) +{ + auto member = members.find(id); + if (member != members.end() && member->second.socket->isOpen()) + member->second.socket->send(message); +} + +static void error_send(int id, const char* message) +{ + send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}"); +} + +// A name as the lobby shows it: printable characters only, cut to length, or "Player" when nothing's left. +static std::string name_clean(std::string_view raw) +{ + std::string name; + for (char c : raw) + if (c >= ' ' && c <= '~' && (int)name.size() < PLAYER_NAME_MAX) + name += c; + return name.empty() ? "Player" : name; +} + +static std::string code_make() +{ + std::uniform_int_distribution letter(0, (int)sizeof(LOBBY_CODE_LETTERS) - 2); + for (int attempt = 0; attempt < CODE_TRIES; attempt++) + { + std::string code; + for (int i = 0; i < LOBBY_CODE_LENGTH; i++) + code += LOBBY_CODE_LETTERS[letter(rng)]; + if (!lobbies.contains(code)) + return code; + } + return ""; +} + +// Puts a member into a lobby and tells everyone: the newcomer gets the whole lobby, the rest get the newcomer. +static void lobby_enter(int id, const std::string& code) +{ + std::vector& lobby = lobbies[code]; + lobby.push_back(id); + members[id].code = code; + std::string list; + for (int other : lobby) + list += (list.empty() ? "" : ",") + std::string("{\"id\":") + std::to_string(other) + + ",\"name\":" + json_quote(members[other].name) + "}"; + send(id, "{\"type\":\"welcome\",\"id\":" + std::to_string(id) + ",\"code\":" + json_quote(code) + + ",\"host\":" + std::to_string(lobby.front()) + ",\"members\":[" + list + "]}"); + for (int other : lobby) + if (other != id) + send(other, "{\"type\":\"member_join\",\"id\":" + std::to_string(id) + + ",\"name\":" + json_quote(members[id].name) + "}"); +} + +// Takes a member out of its lobby, telling the rest (and who hosts now, and why if they were kicked). An empty lobby is +// gone. +static void lobby_exit(int id, const std::string& kick = "", bool isKicked = false) +{ + std::string code = members[id].code; + if (code.empty()) + return; + members[id].code.clear(); + std::vector& lobby = lobbies[code]; + std::erase(lobby, id); + if (lobby.empty()) + { + lobbies.erase(code); + std::printf("Lobby %s closed (%zu open)\n", code.c_str(), lobbies.size()); + return; + } + std::string reason = isKicked ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : ""; + for (int other : lobby) + send(other, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) + + ",\"host\":" + std::to_string(lobby.front()) + reason + "}"); +} + +static void message_handle(int id, const std::string& text) +{ + simdjson::dom::element message; + std::string_view type; + if (parser.parse(text).get(message) || message["type"].get(type)) + return error_send(id, "Bad message"); + + // Answered, so proxies see the connection busy both ways. + if (type == "ping") + return send(id, "{\"type\":\"pong\"}"); + if (type == "create" || type == "join") + { + int64_t version = 0; + std::string_view name; + if (message["version"].get(version) || version != PROTOCOL_VERSION) + return error_send(id, "Different game version"); + if (!members[id].code.empty()) + return error_send(id, "Already in a lobby"); + members[id].name = name_clean(message["name"].get(name) ? "" : name); + if (type == "create") + { + std::string code = code_make(); + if (code.empty()) + return error_send(id, "No free lobby codes"); + std::printf("Lobby %s opened (%zu open)\n", code.c_str(), lobbies.size() + 1); + return lobby_enter(id, code); + } + std::string_view raw; + if (message["code"].get(raw)) + return error_send(id, "No code"); + std::string code; + for (char c : raw) + code += (char)std::toupper((unsigned char)c); + auto lobby = lobbies.find(code); + if (lobby == lobbies.end()) + return error_send(id, "No lobby with that code"); + if ((int)lobby->second.size() >= LOBBY_MEMBERS_MAX) + return error_send(id, "That lobby is full"); + return lobby_enter(id, code); + } + if (type == "leave") + return lobby_exit(id); + if (type == "kick") + { + // Only a lobby's host can kick, and only someone else in their lobby. The kicked member is told why, then is + // out (their connection to the server stays; they can join a lobby again). + int64_t target = 0; + std::string_view raw; + std::string& code = members[id].code; + if (message["id"].get(target) || code.empty() || lobbies[code].front() != id || target == id) + return error_send(id, "Only the host can kick"); + auto kicked = members.find((int)target); + if (kicked == members.end() || kicked->second.code != code) + return error_send(id, "No such player in the lobby"); + std::string reason = text_clean(message["reason"].get(raw) ? "" : raw, KICK_REASON_MAX); + send((int)target, "{\"type\":\"kicked\",\"reason\":" + json_quote(reason) + "}"); + std::printf("Lobby %s: %d kicked %d\n", code.c_str(), id, (int)target); + return lobby_exit((int)target, reason, true); + } + if (type == "signal") + { + // Passed on as it came, only between members of the same lobby. + int64_t to = 0; + simdjson::dom::element payload; + if (message["to"].get(to) || message["payload"].get(payload)) + return error_send(id, "Bad signal"); + auto target = members.find((int)to); + if (target == members.end() || members[id].code.empty() || target->second.code != members[id].code) + return; + send((int)to, + "{\"type\":\"signal\",\"from\":" + std::to_string(id) + ",\"payload\":" + simdjson::minify(payload) + "}"); + return; + } + error_send(id, "Unknown message"); +} + +static volatile std::sig_atomic_t isStopping = 0; + +int main(int argc, char** argv) +{ + std::setvbuf(stdout, nullptr, _IOLBF, 0); + rtc::InitLogger(rtc::LogLevel::Warning); + rtc::WebSocketServerConfiguration config; + config.port = argc > 1 ? (uint16_t)std::atoi(argv[1]) : PORT_DEFAULT; + config.bindAddress = "127.0.0.1"; + config.maxMessageSize = MESSAGE_MAX; + rtc::WebSocketServer server(config); + + server.onClient([](std::shared_ptr socket) { + std::lock_guard guard(lock); + int id = nextId++; + members[id] = {.socket = socket}; + socket->onMessage([id](rtc::message_variant data) { + if (!std::holds_alternative(data)) + return; + std::lock_guard guard(lock); + message_handle(id, std::get(data)); + }); + socket->onClosed([id]() { + std::lock_guard guard(lock); + if (!members.contains(id)) + return; + lobby_exit(id); + members.erase(id); + }); + }); + std::printf("Server listening on 127.0.0.1:%d\n", server.port()); + + std::signal(SIGINT, [](int) { isStopping = 1; }); + std::signal(SIGTERM, [](int) { isStopping = 1; }); + while (!isStopping) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + std::printf("Server stopping\n"); + // Closing calls back into onClosed, which takes the lock, so the sockets are gathered first. + std::vector> sockets; + { + std::lock_guard guard(lock); + for (auto& [id, member] : members) + sockets.push_back(member.socket); + } + for (auto& socket : sockets) + socket->close(); + return 0; +} diff --git a/src/anm2.cpp b/src/anm2.cpp new file mode 100644 index 0000000..f390ec8 --- /dev/null +++ b/src/anm2.cpp @@ -0,0 +1,327 @@ +#include "anm2.h" +#include +#include +#include + +using simdjson::dom::array; +using simdjson::dom::element; +using simdjson::dom::object; + +constexpr float DEGREES = 180; +constexpr float CHANNEL = 255; +constexpr float PERCENT = 100; +constexpr const char* EASING_NAMES[] = {"", "linear", "easeIn", "easeOut", "easeInOut"}; +constexpr const char* GFX_FOLDER = "gfx/"; + +// A spritesheet as packed into the atlas, with its named regions ready to use as keyframes. +struct Anm2Sheet +{ + Rect image; + std::vector regions; +}; + +// Where a document's layers find their art while it's read. +struct Anm2Source +{ + std::vector sheets; + std::vector layerSheets; +}; + +static float number_get(object source, const char* key, float fallback) +{ + double value; + return source[key].get(value) ? fallback : (float)value; +} + +static bool is_get(object source, const char* key, bool fallback) +{ + bool value; + return source[key].get(value) ? fallback : value; +} + +// A list of numbers read into values, keeping each fallback where the list stops short. +static void numbers_get(object source, const char* key, float* values, int count) +{ + array list; + if (source[key].get(list)) + return; + for (int i = 0; i < count; i++) + { + double value; + if (!list.at(i).get(value)) + values[i] = value; + } +} + +static Vec2 pair_get(object source, const char* key, Vec2 fallback) +{ + float values[] = {fallback.x, fallback.y}; + numbers_get(source, key, values, std::size(values)); + return {values[0], values[1]}; +} + +// A keyframe from a track, or a region from a spritesheet. A frame naming a region takes its crop and pivot. +static Anm2Frame frame_parse(object source, Anm2Sheet* sheet) +{ + Anm2Frame frame; + Vec2 crop = pair_get(source, "crop", {}); + Vec2 size = pair_get(source, "size", {}); + frame.pivot = pair_get(source, "pivot", {}); + std::string_view origin; + if (!source["origin"].get(origin)) + frame.pivot = origin == "center" ? size * 0.5f : Vec2{}; + if (sheet) + frame.region = {sheet->image.x + crop.x, sheet->image.y + crop.y, size.x, size.y}; + + int64_t region; + if (sheet && !source["region"].get(region) && region >= 0 && region < (int64_t)sheet->regions.size()) + { + frame.region = sheet->regions[region].region; + frame.pivot = sheet->regions[region].pivot; + } + + float tint[] = {CHANNEL, CHANNEL, CHANNEL, CHANNEL}; + numbers_get(source, "tint", tint, std::size(tint)); + frame.tint = {tint[0] / CHANNEL, tint[1] / CHANNEL, tint[2] / CHANNEL, tint[3] / CHANNEL}; + frame.position = pair_get(source, "pos", {}); + frame.scale = pair_get(source, "scale", {PERCENT, PERCENT}) * (1 / PERCENT); + frame.rotation = number_get(source, "rotation", 0) * PI / DEGREES; + frame.delay = std::max(1, (int)number_get(source, "delay", 1)); + frame.isVisible = is_get(source, "visible", true); + + std::string_view easing; + if (!source["interpolation"].get(easing)) + for (int i = 0; i < (int)std::size(EASING_NAMES); i++) + if (easing == EASING_NAMES[i]) + frame.easing = i; + return frame; +} + +static std::vector track_parse(object source, const char* key, Anm2Sheet* sheet) +{ + std::vector track; + array frames; + if (source[key].get(frames)) + return track; + for (element frame : frames) + { + object fields; + if (!frame.get(fields)) + track.push_back(frame_parse(fields, sheet)); + } + return track; +} + +// Reads every animation in a list, flattening groups (any entry with animations of its own). Layer groups inside an +// animation and hidden tracks are left out. +static void animations_parse(object list, Anm2Source& source, std::vector& animations) +{ + for (auto [key, value] : list) + { + object fields; + object group; + if (value.get(fields)) + continue; + if (!fields["animations"].get(group)) + { + animations_parse(group, source, animations); + continue; + } + + Anm2Animation animation = {.name = std::string(key), .root = track_parse(fields, "root", nullptr)}; + animation.length = std::max(1, (int)number_get(fields, "length", 1)); + animation.isLooping = is_get(fields, "loop", true); + array tracks; + if (!fields["layers"].get(tracks)) + for (element track : tracks) + { + object trackFields; + int64_t layer; + if (track.get(trackFields) || trackFields["layer"].get(layer) || !is_get(trackFields, "visible", true)) + continue; + bool isKnown = layer >= 0 && layer < (int64_t)source.layerSheets.size(); + int sheet = isKnown ? source.layerSheets[layer] : -1; + bool isSheet = sheet >= 0 && sheet < (int)source.sheets.size(); + animation.layers.push_back( + track_parse(trackFields, "frames", isSheet ? &source.sheets[sheet] : nullptr)); + } + animations.push_back(animation); + } +} + +void Anm2::load(Renderer& renderer, const char* name) +{ + std::string path = path_get(GFX_FOLDER) + name + ".anm2"; + size_t size = 0; + char* text = (char*)SDL_LoadFile(path.c_str(), &size); + if (!text) + { + SDL_Log("Failed to load %s: %s", path.c_str(), SDL_GetError()); + return; + } + simdjson::padded_string json(text, size); + SDL_free(text); + + simdjson::dom::parser parser; + object document; + if (parser.parse(json).get(document)) + { + SDL_Log("Failed to parse %s", path.c_str()); + return; + } + + object info; + if (!document["info"].get(info)) + fps = number_get(info, "fps", fps); + + Anm2Source source; + array list; + if (!document["spritesheets"].get(list)) + for (element entry : list) + { + object fields; + std::string_view image; + if (entry.get(fields) || fields["path"].get(image)) + continue; + Anm2Sheet sheet = {renderer.image_load(GFX_FOLDER + std::string(image))}; + for (Anm2Frame& region : track_parse(fields, "regions", &sheet)) + sheet.regions.push_back(region); + source.sheets.push_back(sheet); + } + if (!document["layers"].get(list)) + for (element entry : list) + { + object fields; + source.layerSheets.push_back(entry.get(fields) ? -1 : (int)number_get(fields, "sheet", 0)); + } + + object animationList; + if (!document["animations"].get(animationList)) + animations_parse(animationList, source, animations); +} + +Anm2Animation* Anm2::animation_get(const char* name) +{ + for (Anm2Animation& animation : animations) + if (animation.name == name) + return &animation; + return nullptr; +} + +static float ease(float t, int easing) +{ + switch (easing) + { + case EASING_IN: + return t * t; + case EASING_OUT: + return 1 - (1 - t) * (1 - t); + case EASING_IN_OUT: + return t < 0.5f ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); + default: + return t; + } +} + +// The track's keyframe at a frame, blended toward the next one if it eases. Past the end the last keyframe holds. +static Anm2Frame frame_get(std::vector& track, float frame) +{ + float start = 0; + for (size_t i = 0; i < track.size(); i++) + { + Anm2Frame current = track[i]; + bool isLast = i + 1 == track.size(); + if (!isLast && frame >= start + current.delay) + { + start += current.delay; + continue; + } + if (isLast || current.easing == EASING_NONE) + return current; + + Anm2Frame& next = track[i + 1]; + float t = ease(std::clamp((frame - start) / current.delay, 0.0f, 1.0f), current.easing); + current.position = vec2_lerp(current.position, next.position, t); + current.scale = vec2_lerp(current.scale, next.scale, t); + current.rotation = math_lerp(current.rotation, next.rotation, t); + current.tint = {math_lerp(current.tint.r, next.tint.r, t), math_lerp(current.tint.g, next.tint.g, t), + math_lerp(current.tint.b, next.tint.b, t), math_lerp(current.tint.a, next.tint.a, t)}; + return current; + } + return {}; +} + +// A keyframe's own transform: flipped by the signs of its scale, which turns its rotation the other way when only +// one axis flips. +static Affine frame_affine(Anm2Frame& frame) +{ + float flip = (frame.scale.x < 0) != (frame.scale.y < 0) ? -1 : 1; + return affine_get(frame.position, frame.rotation * flip, frame.scale, frame.pivot); +} + +static Color color_multiply(Color a, Color b) +{ + return {a.r * b.r, a.g * b.g, a.b * b.b, a.a * b.a}; +} + +// Draws an animation's layers at a frame (looping or holding its last), through the root and then the placement. +void Anm2::draw(Renderer& renderer, const char* name, float frame, Draw placement) +{ + Anm2Animation* animation = animation_get(name); + if (!animation) + return; + float length = animation->length; + frame = animation->isLooping ? frame - std::floor(frame / length) * length : std::clamp(frame, 0.0f, length - 1); + + Anm2Frame root = frame_get(animation->root, frame); + if (!root.isVisible) + return; + root.pivot = {}; + Affine base = + affine_get(placement.position, placement.angle, placement.scale, placement.origin) * frame_affine(root); + Color tint = color_multiply(placement.color, root.tint); + for (std::vector& track : animation->layers) + { + Anm2Frame layer = frame_get(track, frame); + if (!track.empty() && layer.isVisible) + renderer.quad_draw(layer.region, base * frame_affine(layer), color_multiply(tint, layer.tint), + placement.flash); + } +} + +void Pose::draw(Renderer& renderer) +{ + if (art) + art->draw(renderer, animation, frame, placement); +} + +void Animator::play(const char* name) +{ + if (SDL_strcmp(name, animation) == 0) + return; + animation = name; + queued = nullptr; + frame = 0; +} + +void Animator::queue(const char* name) +{ + queued = name; +} + +// Moves on by a number of frames, handing over to the queued animation once one that doesn't loop has finished. +void Animator::update(float frames) +{ + frame += frames; + Anm2Animation* current = art ? art->animation_get(animation) : nullptr; + if (!queued || !current || current->isLooping || frame < current->length) + return; + animation = queued; + queued = nullptr; + frame = 0; +} + +Pose Animator::pose_get(Draw placement) +{ + return {.art = art, .animation = animation, .placement = placement, .frame = frame}; +} diff --git a/src/anm2.h b/src/anm2.h new file mode 100644 index 0000000..8fa8393 --- /dev/null +++ b/src/anm2.h @@ -0,0 +1,80 @@ +#pragma once + +#include "renderer.h" +#include +#include + +enum Anm2Easing +{ + EASING_NONE, + EASING_LINEAR, + EASING_IN, + EASING_OUT, + EASING_IN_OUT, +}; + +// One keyframe of a root or layer track. Region is in the atlas and the pivot inside it. Scale is a fraction and +// rotation is in radians. +struct Anm2Frame +{ + Color tint; + Rect region; + Vec2 pivot; + Vec2 position; + Vec2 scale = {1, 1}; + float rotation = 0; + int delay = 1; + int easing = EASING_NONE; + bool isVisible = true; +}; + +// Layers hold one track of keyframes each, drawn in order. +struct Anm2Animation +{ + std::string name; + std::vector root; + std::vector> layers; + int length = 1; + bool isLooping = true; +}; + +// An anm2ed JSON document (resources/gfx/.anm2) with its spritesheets packed into the atlas. Any animation can +// be drawn by name at any frame, placed like a sprite. It reads spritesheets, regions, layers, groups of animations, +// and root and layer keyframes (position, crop, pivot, scale, rotation, tint, visibility and easing). +class Anm2 +{ +public: + std::vector animations; + float fps = 30; + + void load(Renderer& renderer, const char* name); + Anm2Animation* animation_get(const char* name); + void draw(Renderer& renderer, const char* animation, float frame, Draw placement); +}; + +// An animation frame placed like a sprite, ready to draw. Afterimages keep these. +struct Pose +{ + Anm2* art = nullptr; + const char* animation = ""; + Draw placement; + float frame = 0; + + void draw(Renderer& renderer); +}; + +// Plays one sprite's animations by name. Play switches to an animation from its start, unless it's already playing. +// Queue names the one to follow once a non-looping animation finishes. +class Animator +{ +public: + Anm2* art = nullptr; + const char* animation = ""; + const char* queued = nullptr; + float frame = 0; + + void play(const char* name); + void queue(const char* name); + void update(float frames); + Pose pose_get(Draw placement); +}; diff --git a/src/audio.cpp b/src/audio.cpp new file mode 100644 index 0000000..0a16a90 --- /dev/null +++ b/src/audio.cpp @@ -0,0 +1,170 @@ +#include "audio.h" +#include "common.h" +#include + +#include + +constexpr float PITCH_VARIANCE = 0.06f; +constexpr Uint64 SOUND_GAP = 40 * SDL_NS_PER_MS; +constexpr const char* SOUND_NAMES[SOUND_COUNT] = { + "beam", + "blast", + "boomerang", + "boomerang_heavy", + "bounce", + "bow", + "bow_heavy", + "break", + "cast", + "charge", + "chat", + "crawl", + "dash", + "death", + "detonate", + "detonate_empty", + "duck", + "echo", + "equip", + "explosion", + "fall", + "finish", + "fireball", + "flail", + "flail_heavy", + "flail_land", + "flame", + "grapple", + "grapple_heavy", + "hit_blunt", + "hit_blunt_heavy", + "hit_bullet", + "hit_bullet_heavy", + "hit_energy", + "hit_energy_heavy", + "hit_explosive", + "hit_explosive_heavy", + "hit_fire", + "hit_fire_heavy", + "hit_lash", + "hit_lash_heavy", + "hit_magic", + "hit_magic_heavy", + "hit_pierce", + "hit_pierce_heavy", + "hit_slash", + "hit_slash_heavy", + "impact_arrow", + "impact_blaster", + "impact_boomerang", + "impact_bullet", + "impact_mine", + "jump", + "land", + "latch", + "level_down", + "level_up", + "mine", + "poke", + "poke_heavy", + "pound", + "punch", + "ready", + "ricochet", + "rocket", + "rocket_heavy", + "roll", + "select", + "shotgun", + "shotgun_heavy", + "slam", + "slash", + "slash_heavy", + "step", + "swap", + "swing", + "swing_heavy", + "teleport", + "teleport_warp", + "unduck", + "unequip", + "uppercut", + "uzi", + "uzi_heavy", + "whip", + "whip_heavy", + "wisp", + "zap", +}; + +void Audio::init() +{ + if (SDL_InitSubSystem(SDL_INIT_AUDIO)) + device = SDL_OpenAudioDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); + if (!device) + { + SDL_Log("Failed to open audio: %s", SDL_GetError()); + return; + } + + for (int i = 0; i < SOUND_COUNT; i++) + { + std::string path = path_get("sfx/") + SOUND_NAMES[i] + ".ogg"; + Sound& sound = sounds[i]; + sound.frames = stb_vorbis_decode_filename(path.c_str(), &sound.channels, &sound.rate, &sound.samples); + if (sound.frames <= 0) + SDL_Log("Failed to load %s", path.c_str()); + } + + SDL_AudioSpec deviceSpec = {}; + SDL_GetAudioDeviceFormat(device, &deviceSpec, nullptr); + for (SDL_AudioStream*& voice : voices) + { + voice = SDL_CreateAudioStream(&deviceSpec, &deviceSpec); + SDL_BindAudioStream(device, voice); + } +} + +// Plays a sound with a slight random pitch and returns the voice used, or -1 if it was skipped. +int Audio::play(SoundId id) +{ + if (id == SOUND_NONE) + return -1; + Sound& sound = sounds[id]; + Uint64 now = SDL_GetTicksNS(); + if (!device || sound.frames <= 0 || isMuted || now - sound.played < SOUND_GAP) + return -1; + sound.played = now; + + int voice = voiceNext; + for (int i = 0; i < VOICE_COUNT; i++) + { + if (SDL_GetAudioStreamQueued(voices[i]) == 0) + { + voice = i; + break; + } + } + voiceNext = (voice + 1) % VOICE_COUNT; + + SDL_AudioStream* stream = voices[voice]; + SDL_AudioSpec spec = {SDL_AUDIO_S16, sound.channels, sound.rate}; + SDL_ClearAudioStream(stream); + SDL_SetAudioStreamFormat(stream, &spec, nullptr); + SDL_SetAudioStreamFrequencyRatio(stream, random_get(1 - PITCH_VARIANCE, 1 + PITCH_VARIANCE)); + SDL_PutAudioStreamData(stream, sound.samples, sound.frames * sound.channels * sizeof(short)); + return voice; +} + +// Sets the volume of everything played, from silent at 0 to full at 1. +void Audio::gain_set(float gain) +{ + if (device) + SDL_SetAudioDeviceGain(device, gain); +} + +void Audio::stop(int voice) +{ + if (voice >= 0) + SDL_ClearAudioStream(voices[voice]); +} diff --git a/src/audio.h b/src/audio.h new file mode 100644 index 0000000..960d55f --- /dev/null +++ b/src/audio.h @@ -0,0 +1,128 @@ +#pragma once + +struct SDL_AudioStream; + +constexpr int VOICE_COUNT = 24; + +enum SoundId +{ + SOUND_NONE = -1, + SOUND_BEAM, + SOUND_BLAST, + SOUND_BOOMERANG, + SOUND_BOOMERANG_HEAVY, + SOUND_BOUNCE, + SOUND_BOW, + SOUND_BOW_HEAVY, + SOUND_BREAK, + SOUND_CAST, + SOUND_CHARGE, + SOUND_CHAT, + SOUND_CRAWL, + SOUND_DASH, + SOUND_DEATH, + SOUND_DETONATE, + SOUND_DETONATE_EMPTY, + SOUND_DUCK, + SOUND_ECHO, + SOUND_EQUIP, + SOUND_EXPLOSION, + SOUND_FALL, + SOUND_FINISH, + SOUND_FIREBALL, + SOUND_FLAIL, + SOUND_FLAIL_HEAVY, + SOUND_FLAIL_LAND, + SOUND_FLAME, + SOUND_GRAPPLE, + SOUND_GRAPPLE_HEAVY, + SOUND_HIT_BLUNT, + SOUND_HIT_BLUNT_HEAVY, + SOUND_HIT_BULLET, + SOUND_HIT_BULLET_HEAVY, + SOUND_HIT_ENERGY, + SOUND_HIT_ENERGY_HEAVY, + SOUND_HIT_EXPLOSIVE, + SOUND_HIT_EXPLOSIVE_HEAVY, + SOUND_HIT_FIRE, + SOUND_HIT_FIRE_HEAVY, + SOUND_HIT_LASH, + SOUND_HIT_LASH_HEAVY, + SOUND_HIT_MAGIC, + SOUND_HIT_MAGIC_HEAVY, + SOUND_HIT_PIERCE, + SOUND_HIT_PIERCE_HEAVY, + SOUND_HIT_SLASH, + SOUND_HIT_SLASH_HEAVY, + SOUND_IMPACT_ARROW, + SOUND_IMPACT_BLASTER, + SOUND_IMPACT_BOOMERANG, + SOUND_IMPACT_BULLET, + SOUND_IMPACT_MINE, + SOUND_JUMP, + SOUND_LAND, + SOUND_LATCH, + SOUND_LEVEL_DOWN, + SOUND_LEVEL_UP, + SOUND_MINE, + SOUND_POKE, + SOUND_POKE_HEAVY, + SOUND_POUND, + SOUND_PUNCH, + SOUND_READY, + SOUND_RICOCHET, + SOUND_ROCKET, + SOUND_ROCKET_HEAVY, + SOUND_ROLL, + SOUND_SELECT, + SOUND_SHOTGUN, + SOUND_SHOTGUN_HEAVY, + SOUND_SLAM, + SOUND_SLASH, + SOUND_SLASH_HEAVY, + SOUND_STEP, + SOUND_SWAP, + SOUND_SWING, + SOUND_SWING_HEAVY, + SOUND_TELEPORT, + SOUND_TELEPORT_WARP, + SOUND_UNDUCK, + SOUND_UNEQUIP, + SOUND_UPPERCUT, + SOUND_UZI, + SOUND_UZI_HEAVY, + SOUND_WHIP, + SOUND_WHIP_HEAVY, + SOUND_WISP, + SOUND_ZAP, + SOUND_COUNT +}; + +// Played is when the sound last started, in nanoseconds since SDL started. +struct Sound +{ + unsigned long long played = 0; + short* samples = nullptr; + int channels = 0; + int frames = 0; + int rate = 0; +}; + +// Decodes every ogg up front and plays them through a pool of streams that SDL mixes together. A sound asked for +// again within SOUND_GAP of its last start is skipped, so a volley landing at once sounds like one hit. +class Audio +{ +public: + bool isMuted = false; + + void init(); + int play(SoundId id); + void stop(int voice); + void gain_set(float gain); + +private: + Sound sounds[SOUND_COUNT]; + SDL_AudioStream* voices[VOICE_COUNT] = {}; + unsigned int device = 0; + int voiceNext = 0; +}; diff --git a/src/balance.cpp b/src/balance.cpp new file mode 100644 index 0000000..633fd32 --- /dev/null +++ b/src/balance.cpp @@ -0,0 +1,167 @@ +#include "balance.h" +#include "state.h" +#include "game.h" +#include +#include + +constexpr int STATE_TEST_TICK = 600; +constexpr int PROGRESS_STEPS = 10; +constexpr float CONFIDENCE_Z = 1.96f; +constexpr float MATCH_TIME_LIMIT = 300; +constexpr float PERCENT = 100; +constexpr Uint32 HASH_PRIME = 16777619u; + +// How often an item was carried into a finished match, how often its carrier won, and how many matches it was in +// that stalled out at the time limit. +struct Tally +{ + const char* name = ""; + int picks = 0; + int stalls = 0; + int wins = 0; +}; + +// Prints one table, best win rate first, with a 95% margin of error on each rate. +static void tallies_print(const char* title, Tally* tallies, int count) +{ + std::sort(tallies, tallies + count, + [](Tally& a, Tally& b) { return a.wins * (float)b.picks > b.wins * (float)a.picks; }); + SDL_Log("%s", title); + for (int i = 0; i < count; i++) + { + Tally& tally = tallies[i]; + float rate = tally.picks > 0 ? (float)tally.wins / tally.picks : 0; + float margin = tally.picks > 0 ? CONFIDENCE_Z * SDL_sqrtf(rate * (1 - rate) / tally.picks) : 0; + SDL_Log(" %-16s %5.1f%% +-%4.1f (%d wins / %d picks, %d stalls)", tally.name, rate * PERCENT, + margin * PERCENT, tally.wins, tally.picks, tally.stalls); + } +} + +// RPG_STATE_TEST: partway through each match the game is saved, wiped and loaded back, then plays on. If saving and +// loading carry everything that matters, the run's determinism hash comes out the same as without it. For the first +// match, RPG_STATE_PRINT prints the saved state as hex, and RPG_STATE_IMPORT_HEX loads that hex (from another build or +// platform) in place of this run's own: if the platforms lay the state out the same, the hash still comes out the same. +// (The bytes themselves can differ between builds, in the padding between fields, which nothing reads.) +static void state_test(Game& game, bool isFirst) +{ + std::string bytes = state_save(game); + const char* imported = SDL_getenv("RPG_STATE_IMPORT_HEX"); + if (isFirst && SDL_getenv("RPG_STATE_PRINT")) + { + std::string hex; + for (unsigned char byte : bytes) + { + hex += "0123456789abcdef"[byte >> 4]; + hex += "0123456789abcdef"[byte & 15]; + } + SDL_Log("State hex: %s", hex.c_str()); + } + if (isFirst && imported) + { + bytes.clear(); + for (const char* digit = imported; digit[0] && digit[1]; digit += 2) + { + auto value = [](char c) { + return c <= '9' ? c - '0' : c - 'a' + 10; + }; + bytes += (char)(value(digit[0]) << 4 | value(digit[1])); + } + } + for (Fighter& fighter : game.fighters) + fighter = Fighter{}; + game.weapons.attacks.clear(); + game.world.dummies.clear(); + if (!state_load(game, bytes)) + SDL_Log("State test: the saved state didn't load"); + else if (!imported && state_save(game) != bytes) + SDL_Log("State test: the state saved after loading differs"); +} + +void Balance::matches_run(Game& game, int count, MatchMode mode) +{ + Tally weapons[WEAPON_COUNT]; + Tally trinkets[TRINKET_COUNT]; + for (int i = 0; i < WEAPON_COUNT; i++) + weapons[i].name = WEAPON_DEFS[i].name; + for (int i = 0; i < TRINKET_COUNT; i++) + trinkets[i].name = TRINKET_DEFS[i].name; + + game.audio.isMuted = true; + game.effects.isMuted = true; + for (Fighter& fighter : game.fighters) + fighter.isBot = true; + int limit = MATCH_TIME_LIMIT / TICK; + int timeouts = 0; + double ticks = 0; + double winTimes = 0; + int finishes = 0; + int racers = 0; + int wins = 0; + Uint64 start = SDL_GetPerformanceCounter(); + Uint32 hash = 0; + bool isStateTest = SDL_getenv("RPG_STATE_TEST") != nullptr; + + for (int match = 0; match < count; match++) + { + // Items dealt at random and kept at their start level, so a weapon's win rate measures the weapon. + game.match.rules = rules_get(mode); + for (ItemRules* items : {&game.match.rules.weapons, &game.match.rules.trinkets}) + { + items->isChoice = false; + items->isLevelUp = false; + } + game.match.start(game); + int tick = 0; + for (; game.match.state != MATCH_OVER && tick < limit; tick++) + { + game_tick(game); + hash = (hash ^ game.rollback.checksum_get(game)) * HASH_PRIME; + if (tick == STATE_TEST_TICK && isStateTest) + state_test(game, match == 0); + } + ticks += tick; + if (count >= PROGRESS_STEPS && (match + 1) % (count / PROGRESS_STEPS) == 0) + SDL_Log("%d / %d matches", match + 1, count); + bool isStalled = game.match.state != MATCH_OVER; + timeouts += isStalled; + racers += game.fighterCount; + for (int i = 0; i < game.fighterCount; i++) + finishes += game.match.finishTimes[i] >= 0; + if (game.match.winner >= 0) + { + wins++; + winTimes += game.match.finishTimes[game.match.winner]; + } + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + bool isWinner = i == game.match.winner; + for (int weapon : fighter.weaponLoadout) + { + if (weapon < 0) + continue; + weapons[weapon].picks += !isStalled; + weapons[weapon].stalls += isStalled; + weapons[weapon].wins += !isStalled && isWinner; + } + for (int trinket : fighter.trinketLoadout) + { + if (trinket < 0) + continue; + trinkets[trinket].picks += !isStalled; + trinkets[trinket].stalls += isStalled; + trinkets[trinket].wins += !isStalled && isWinner; + } + } + } + + double seconds = (SDL_GetPerformanceCounter() - start) / (double)SDL_GetPerformanceFrequency(); + SDL_Log("%d matches in %.1fs (%.0fx real time). Average match %.1fs of game time, %d hit the %.0fs limit.", count, + seconds, ticks * TICK / seconds, ticks * TICK / count, timeouts, MATCH_TIME_LIMIT); + if (mode == MODE_RACE) + SDL_Log("%d / %d racers finished. Average winning time %.1fs.", finishes, racers, + wins > 0 ? winTimes / wins : 0); + SDL_Log("Determinism hash: %08x (same seed, same hash on every build and platform)", hash); + tallies_print("Weapons (win rate of whoever carried it):", weapons, WEAPON_COUNT); + tallies_print("Trinkets:", trinkets, TRINKET_COUNT); +} diff --git a/src/balance.h b/src/balance.h new file mode 100644 index 0000000..b01d279 --- /dev/null +++ b/src/balance.h @@ -0,0 +1,15 @@ +#pragma once + +#include "match.h" + +struct Game; + +// Headless bot-vs-bot matches for balancing: plays matches as fast as the machine allows with every fighter a bot, +// then prints win rates for every weapon and trinket, how long matches ran, and how many hit the time limit. Races +// also report how many racers finished and the average winning time. A hash of every tick's state is printed too: +// with the same seed (RPG_SEED) every build, on every platform, must print the same one. +class Balance +{ +public: + void matches_run(Game& game, int count, MatchMode mode); +}; diff --git a/src/bot.cpp b/src/bot.cpp new file mode 100644 index 0000000..9403637 --- /dev/null +++ b/src/bot.cpp @@ -0,0 +1,966 @@ +#include "bot.h" +#include "game.h" +#include +#include + +constexpr int PURSUIT_TICKS = 60; +constexpr int SURFACE_CLEARANCE = 2; +constexpr float AIM_READY = 0.3f; +constexpr float BLOCK_SEEK = 3 * TILE_SIZE; +constexpr float ARRIVE_DISTANCE = 6; +constexpr float CHARGE_START_REACH = 1.5f; +constexpr float BOOMERANG_OVERSHOOT = 24; +constexpr float CHARGE_CHANCE = 0.35f; +constexpr float CHARGE_HOLD_MAX = 1.5f; +constexpr float DASH_SHORTFALL = 3 * TILE_SIZE; +constexpr float DASH_CHASE = 120; +constexpr float DASH_DODGE = 60; +constexpr float DASH_SHORT_RANGE = 100; +constexpr float CLIMB_JUMP_SPEED = 160; +constexpr float CLIMB_RISE = TILE_SIZE; +constexpr float FEATHER_REACH = 2 * TILE_SIZE; +constexpr float POUND_REACH = 40; +constexpr float DODGE_RADIUS = 18; +constexpr float DODGE_TIME = 0.4f; +constexpr float FEET_TOLERANCE = 2; +constexpr float FLAIL_HOLD = 1; +constexpr float GRAPPLE_COST = 20; +constexpr float GRAPPLE_GRIP = 4; +constexpr float GRAPPLE_RISE_MAX = 200; +constexpr float GRAPPLE_TIME = 2.5f; +constexpr float GOAL_SPAN = 0.5f; +constexpr float JUMP_COST = 40; +constexpr float JUMP_REACH = 0.8f; +constexpr float JUMP_RISE_MAX = 64; +constexpr float LINK_WIDTH = 1; +constexpr float RAY_STEP = 4; +constexpr float RECOVER_AIM = 48; +constexpr float RECOVER_TIME = 0.25f; +constexpr float RETREAT_MARGIN = 24; +constexpr float STRAFE_DISTANCE = 40; +constexpr float STUCK_SPEED = 5; +constexpr float STUCK_TIME = 0.5f; +constexpr float SEAM_TOLERANCE = 2; +constexpr float SURFACE_WIDTH = 2; +constexpr float TAKEOFF_INSET = TILE_SIZE; +constexpr float THREAT_SPEED = 50; +constexpr float WHIP_SWEET_SPOT = 8; +constexpr float WISP_GATHER = 1; + +static void control_set(Controls& controls, Control control, bool isDown) +{ + controls.buttons |= isDown << control; +} + +// Presses a button only if it wasn't held last tick, so it registers as a fresh press. +static void control_tap(Fighter& fighter, Controls& controls, Control control) +{ + control_set(controls, control, !(fighter.controls.buttons >> control & 1)); +} + +// The loadout slot holding a grapple, or -1. +static int grapple_slot_get(Fighter& fighter) +{ + for (int slot = 0; slot < fighter.weaponSlots; slot++) + { + int weapon = fighter.weaponLoadout[slot]; + if (weapon >= 0 && WEAPON_DEFS[weapon].normal.kind == ATTACK_HOOK) + return slot; + } + return -1; +} + +static bool is_sight_clear(Game& game, Vec2 from, Vec2 to) +{ + Vec2 delta = to - from; + int steps = vec2_length(delta) / RAY_STEP; + for (int i = 1; i < steps; i++) + if (game.world.is_blocking_at(from + delta * ((float)i / steps))) + return false; + return true; +} + +// How far a running jump carries sideways before landing rise pixels higher (or lower, when negative). +static float jump_reach_get(float rise) +{ + float discriminant = JUMP_SPEED * JUMP_SPEED - 2 * GRAVITY * rise; + if (discriminant < 0) + return -1; + return RUN_SPEED * (JUMP_SPEED + std::sqrt(discriminant)) / GRAVITY; +} + +// A weapon slot's hint, its band capped by how far the planned attack really reaches at the weapon's level. A +// charged whip only blows up at the very tip, so its band narrows to the tip's reach. +static BotHint hint_get(Fighter& fighter, int weaponSlot, bool isCharged) +{ + int weapon = fighter.weaponLoadout[weaponSlot]; + BotHint hint = WEAPON_DEFS[weapon].bot; + const LevelDef& level = fighter.level_get(weaponSlot); + float range = isCharged ? level.charged.range : level.normal.range; + hint.rangeMax = std::min(hint.rangeMax, range + BOX_WIDTH / 2); + hint.rangeMin = std::min(hint.rangeMin, hint.rangeMax); + if (isCharged && WEAPON_DEFS[weapon].charged.kind == ATTACK_WHIP) + { + hint.rangeMin = range - WHIP_SWEET_SPOT; + hint.rangeMax = range + WHIP_SWEET_SPOT; + } + return hint; +} + +// Where the target was ticksAgo ticks back, as far back as the bot has seen it. +static Vec2 seen_get(BotState& state, int ticksAgo) +{ + int back = std::min(ticksAgo, state.seenCount - 1); + return state.seen[(state.seenHead - back + BOT_HISTORY) % BOT_HISTORY]; +} + +static void seen_add(BotState& state, Vec2 position) +{ + state.seenHead = (state.seenHead + 1) % BOT_HISTORY; + state.seen[state.seenHead] = position; + state.seenCount = std::min(state.seenCount + 1, BOT_HISTORY); +} + +// Where a launched fighter will come to rest: its flight played forward with the real launch physics, bouncing off +// whatever it meets, until it slows down or the look-ahead runs out. +static Vec2 landing_predict(Game& game, Fighter& fighter) +{ + Body body = fighter.body; + for (int i = 0; i < PURSUIT_TICKS && vec2_length(body.velocity) >= LAUNCH_END_SPEED; i++) + { + Vec2 before = body.velocity * (1 - KNOCK_DECAY * TICK) + Vec2{0, GRAVITY * TICK}; + body.velocity = before; + game.world.body_move(body); + if (body.velocity.x == 0 && std::abs(before.x) > BOUNCE_MIN) + body.velocity.x = -before.x * BOUNCE_KEEP; + if (body.velocity.y == 0 && std::abs(before.y) > BOUNCE_MIN) + body.velocity.y = -before.y * BOUNCE_KEEP; + } + return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2}; +} + +// Launched toward a slam, grab whatever softens it and use it toward the wall: a hammer swing to rebound off it, +// flames to fly into and brake, or a shot whose recoil pushes back. Reports whether it's doing so. +static bool is_recovering(Game& game, Fighter& fighter, Controls& controls) +{ + Vec2 velocity = fighter.body.velocity; + Vec2 center = fighter.center_get(); + bool isHeading = !is_sight_clear(game, center, center + velocity * RECOVER_TIME); + if (!fighter.isLaunched || vec2_length(velocity) < SLAM_SPEED || !isHeading) + return false; + + int best = -1; + int bestRank = 0; + for (int slot = 0; slot < fighter.weaponSlots; slot++) + { + int weapon = fighter.weaponLoadout[slot]; + if (weapon < 0) + continue; + AttackKind kind = WEAPON_DEFS[weapon].normal.kind; + int rank = kind == ATTACK_SWING ? 3 : kind == ATTACK_FLAME ? 2 : fighter.level_get(slot).normal.lunge < 0; + if (rank > bestRank) + { + best = slot; + bestRank = rank; + } + } + if (best < 0) + return false; + + if (fighter.slot != best) + controls.slot = best; + controls.cursor = center + vec2_normalize(velocity) * RECOVER_AIM; + control_set(controls, CONTROL_ATTACK, fighter.slot == best); + return true; +} + +// Whether an enemy attack in flight is on course to pass through the fighter soon. +static bool is_threatened(Game& game, Fighter& fighter) +{ + Vec2 center = fighter.center_get(); + for (Attack& attack : game.weapons.attacks) + { + float speed = vec2_length(attack.velocity); + if (attack.owner == fighter.index || attack.isDead || attack.isAttached || speed < THREAT_SPEED) + continue; + Vec2 toward = center - attack.position; + float time = vec2_dot(toward, attack.velocity) / (speed * speed); + if (time > 0 && time < DODGE_TIME && vec2_length(toward - attack.velocity * time) < DODGE_RADIUS) + return true; + } + return false; +} + +void Bot::init(Game& game) +{ + isDrawn = SDL_getenv("RPG_BOT_DEBUG"); + const char* chosen = SDL_getenv("RPG_BOT_DIFFICULTY"); + if (chosen) + level = std::clamp(SDL_atoi(chosen), 0, BOT_LEVEL_COUNT - 1); + graph_build(game.world); +} + +void Bot::graph_build(World& world) +{ + surfaces.clear(); + links.clear(); + surfaces_build(world); + links_build(); + linkStarts.assign(surfaces.size() + 1, (int)links.size()); + for (int i = (int)links.size() - 1; i >= 0; i--) + linkStarts[links[i].from] = i; + for (int i = (int)surfaces.size() - 1; i >= 0; i--) + linkStarts[i] = std::min(linkStarts[i], linkStarts[i + 1]); +} + +// Breakable blocks count as open air, since bots smash through them, and finish lines are open too. +static bool is_clear(Tile tile) +{ + return tile == TILE_EMPTY || tile == TILE_BLOCK || tile == TILE_FINISH; +} + +// Every run of solid or platform tiles with room above for a fighter to stand, split where the tile type changes, +// and every ramp gentle enough to stand on. +void Bot::surfaces_build(World& world) +{ + for (Ramp& ramp : world.ramps) + { + if (ramp.isSteep) + continue; + float left = ramp.left + BOX_WIDTH / 2; + surfaces.push_back({.left = left, + .right = ramp.right - BOX_WIDTH / 2, + .slope = ramp.slope, + .y = ramp.base + ramp.slope * (left - ramp.anchor), + .yMax = ramp.base, + .yMin = ramp.top}); + } + + for (int y = SURFACE_CLEARANCE; y < world.height; y++) + { + int start = -1; + for (int x = 0; x <= world.width; x++) + { + Tile tile = x < world.width ? world.tile_get(x, y) : TILE_EMPTY; + bool isStandable = (tile == TILE_SOLID || tile == TILE_PLATFORM) && is_clear(world.tile_get(x, y - 1)) && + is_clear(world.tile_get(x, y - 2)); + bool isSame = start >= 0 && isStandable && tile == world.tile_get(start, y); + if (start >= 0 && !isSame) + { + surfaces.push_back({ + .left = start * TILE_SIZE + BOX_WIDTH / 2, + .right = x * TILE_SIZE - BOX_WIDTH / 2, + .y = (float)y * TILE_SIZE, + .yMax = (float)y * TILE_SIZE, + .yMin = (float)y * TILE_SIZE, + .isPlatform = world.tile_get(start, y) == TILE_PLATFORM, + }); + start = -1; + } + if (start < 0 && isStandable) + start = x; + } + } +} + +// The first surface under a point, skipping any within above pixels of it, or -1. +int Bot::surface_below(Vec2 point, float above) +{ + int best = -1; + for (int i = 0; i < (int)surfaces.size(); i++) + { + Surface& surface = surfaces[i]; + bool isUnder = point.x >= surface.left && point.x <= surface.right && surface.y_at(point.x) > point.y + above; + if (isUnder && (best < 0 || surface.y_at(point.x) < surfaces[best].y_at(point.x))) + best = i; + } + return best; +} + +// Links between every pair of surfaces a fighter can travel between: neighbours in a row, falls off either end, +// drops through platforms, jumps within a running jump's reach, and grapple pulls straight up to platforms higher +// than that. Jumps can't go up through solid ground. +void Bot::links_build() +{ + for (int a = 0; a < (int)surfaces.size(); a++) + { + Surface& from = surfaces[a]; + for (int side = -1; side <= 1; side += 2) + { + float end = side < 0 ? from.left : from.right; + float edge = end + side * BOX_WIDTH; + int below = surface_below({edge, from.y_at(end)}, 0); + if (below >= 0) + links.push_back( + {LINK_FALL, std::abs(surfaces[below].y_at(edge) - from.y_at(end)), edge, end, a, below}); + } + + for (int b = 0; b < (int)surfaces.size(); b++) + { + Surface& to = surfaces[b]; + if (a == b) + continue; + float overlapLeft = std::max(from.left, to.left); + float overlapRight = std::min(from.right, to.right); + float middle = (overlapLeft + overlapRight) / 2; + bool isOverlapping = overlapLeft <= overlapRight; + bool isRight = to.left > from.right; + float seam = isRight ? (from.right + to.left) / 2 : (from.left + to.right) / 2; + bool isAdjacent = + std::abs(to.left - from.right) <= BOX_WIDTH + 1 || std::abs(from.left - to.right) <= BOX_WIDTH + 1; + bool isTouching = isAdjacent && std::abs(from.y_at(seam) - to.y_at(seam)) < SEAM_TOLERANCE; + + if (isTouching) + { + links.push_back({LINK_WALK, 0, seam + (isRight ? TAKEOFF_INSET : -TAKEOFF_INSET), seam, a, b}); + continue; + } + float fromY = from.y_at(isOverlapping ? middle : isRight ? from.right : from.left); + float toY = to.y_at(isOverlapping ? middle : isRight ? to.left : to.right); + if (from.isPlatform && isOverlapping && toY > fromY && surface_below({middle, fromY}, 0) == b) + links.push_back({LINK_DROP, toY - fromY, middle, middle, a, b}); + + float rise = fromY - toY; + if (isOverlapping && to.isPlatform && rise > JUMP_RISE_MAX && rise <= GRAPPLE_RISE_MAX) + links.push_back({LINK_GRAPPLE, GRAPPLE_COST, middle, middle, a, b}); + float reach = jump_reach_get(rise); + if (rise > JUMP_RISE_MAX || reach < 0 || (isOverlapping && rise > 0 && !to.isPlatform)) + continue; + if (isOverlapping && rise > 0) + { + links.push_back({LINK_JUMP, JUMP_COST, middle, middle, a, b}); + continue; + } + + float gap = to.left > from.right ? to.left - from.right : from.left - to.right; + if (isOverlapping || gap > reach * JUMP_REACH) + continue; + float takeoff = isRight ? std::max(from.left, from.right - TAKEOFF_INSET) + : std::min(from.right, from.left + TAKEOFF_INSET); + float landing = + isRight ? std::min(to.right, to.left + TAKEOFF_INSET) : std::max(to.left, to.right - TAKEOFF_INSET); + links.push_back({LINK_JUMP, JUMP_COST + gap, landing, takeoff, a, b}); + } + } +} + +// The surface the fighter is standing on (the nearest one at its feet' height), or -1 in the air. +int Bot::surface_find(Fighter& fighter) +{ + if (!fighter.body.isGrounded) + return -1; + Vec2 feet = {fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}; + int best = -1; + float bestOutside = BOX_WIDTH; + for (int i = 0; i < (int)surfaces.size(); i++) + { + Surface& surface = surfaces[i]; + float outside = std::max({0.0f, surface.left - feet.x, feet.x - surface.right}); + if (std::abs(surface.y_at(feet.x) - feet.y) < FEET_TOLERANCE && outside <= bestOutside) + { + best = i; + bestOutside = outside; + } + } + return best; +} + +// Cheapest chain of links between two surfaces, stored in the state's path, using grapple links only for a grappler. +// Reports whether there is one. +bool Bot::is_path_found(BotState& state, int from, int to, bool isGrappler) +{ + std::vector costs(surfaces.size(), INFINITY); + std::vector via(surfaces.size(), -1); + std::vector isDone(surfaces.size(), false); + costs[from] = 0; + + while (true) + { + int current = -1; + for (int i = 0; i < (int)surfaces.size(); i++) + if (!isDone[i] && costs[i] < INFINITY && (current < 0 || costs[i] < costs[current])) + current = i; + if (current < 0 || current == to) + break; + isDone[current] = true; + for (int i = linkStarts[current]; i < linkStarts[current + 1]; i++) + { + Link& link = links[i]; + float cost = costs[current] + link.cost + std::abs(link.takeoff - link.landing); + if (cost < costs[link.to] && (isGrappler || link.kind != LINK_GRAPPLE)) + { + costs[link.to] = cost; + via[link.to] = i; + } + } + } + if (costs[to] == INFINITY) + return false; + + // Long paths keep only their first links. The bot re-plans long before it runs out. + std::vector reversed; + for (int surface = to; surface != from; surface = links[via[surface]].from) + reversed.push_back(via[surface]); + int length = std::min((int)reversed.size(), BOT_PATH_MAX); + state.pathLength = length; + state.pathStep = 0; + for (int i = 0; i < length; i++) + state.path[i] = reversed[reversed.size() - 1 - i]; + return true; +} + +// Writes every living bot's controls for this tick, re-thinking on its interval. +void Bot::update(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + BotState& state = states[i]; + if (!fighter.isBot || !fighter.isAlive) + continue; + + bool isRacing = game.match.is_racing(); + state.thinkTimer -= TICK; + if (state.thinkTimer <= 0 && isRacing) + race_think(fighter, state); + else if (state.thinkTimer <= 0) + think(game, fighter, state); + if (state.target >= 0) + seen_add(state, game.fighters[state.target].center_get()); + Controls controls = {.cursor = fighter.controls.cursor}; + if (is_recovering(game, fighter, controls)) + { + fighter.controls = controls; + continue; + } + move_steer(game, fighter, state, controls); + if (isRacing && !state.isGrappling) + block_attack(game, fighter, state, controls); + else if (!state.isGrappling) + attack_aim(game, fighter, state, controls); + wings_steer(fighter, state, controls); + if (!state.isGrappling && !isRacing) + trinket_steer(game, fighter, state, controls); + fighter.controls = controls; + } +} + +// Picks the nearest enemy, the slot whose band fits the distance, whether to charge next, this think's aim error, +// strafe and alertness, and where to stand: out of reach of an enemy holding a full charge (unless cornered), +// drifting about when in +// band with a clear shot, otherwise at band distance from the target on the surface under it. +void Bot::think(Game& game, Fighter& fighter, BotState& state) +{ + const BotDifficulty& skill = BOT_DIFFICULTIES[level]; + Rng& rng = game.rng; + int previous = state.target; + state.thinkTimer = skill.thinkInterval; + state.aimError = rng.random_get(-skill.aimError, skill.aimError); + state.isAlert = rng.random_get(0, 1) < skill.dodgeChance; + state.strafe = (int)rng.random_get(0, 3) - 1; + state.target = -1; + Vec2 center = fighter.center_get(); + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& other = game.fighters[i]; + bool isCloser = state.target < 0 || vec2_length(other.center_get() - center) < + vec2_length(game.fighters[state.target].center_get() - center); + if (i != fighter.index && other.isAlive && isCloser) + state.target = i; + } + if (state.target < 0) + return; + if (state.target != previous) + state.seenCount = 0; + if (state.seenCount == 0) + seen_add(state, game.fighters[state.target].center_get()); + + Fighter& target = game.fighters[state.target]; + Vec2 seen = seen_get(state, skill.reactionTicks); + float distance = vec2_length(seen - fighter.hand_get()); + float bestMiss = INFINITY; + for (int slot = 0; slot < fighter.weaponSlots; slot++) + { + int weapon = fighter.weaponLoadout[slot]; + if (weapon < 0) + continue; + BotHint hint = hint_get(fighter, slot, false); + float miss = std::max({0.0f, hint.rangeMin - distance, distance - hint.rangeMax}); + if (miss < bestMiss) + { + bestMiss = miss; + state.slot = slot; + } + } + + int weapon = fighter.weapon_get(); + ChargePolicy policy = weapon >= 0 ? WEAPON_DEFS[weapon].bot.charge : CHARGE_NEVER; + if (fighter.charge <= 0) + state.isChargePlanned = + policy == CHARGE_ALWAYS || (policy == CHARGE_SOMETIMES && rng.random_get(0, 1) < CHARGE_CHANCE); + BotHint hint = weapon >= 0 ? hint_get(fighter, fighter.slot, state.isChargePlanned) : BotHint{}; + + float side = center.x < seen.x ? -1 : 1; + int current = surface_find(fighter); + int enemyWeapon = target.weapon_get(); + if (state.isAlert && current >= 0 && target.charge >= 1 && enemyWeapon >= 0) + { + float danger = hint_get(target, target.slot, true).rangeMax + RETREAT_MARGIN; + Surface& surface = surfaces[current]; + float away = std::clamp(seen.x + side * danger, surface.left, surface.right); + bool isCornered = std::abs(away - center.x) <= ARRIVE_DISTANCE; + if (distance < danger && !isCornered) + { + state.goal = {away, surface.y_at(away)}; + state.pathLength = 0; + return; + } + } + + bool isInBand = distance >= hint.rangeMin && distance <= hint.rangeMax; + if (isInBand && is_sight_clear(game, fighter.hand_get(), seen)) + { + state.goal = center; + bool isNarrow = hint.rangeMax - hint.rangeMin < STRAFE_DISTANCE * 2; + if (current >= 0 && !isNarrow) + state.goal.x = + std::clamp(center.x + state.strafe * STRAFE_DISTANCE, surfaces[current].left, surfaces[current].right); + state.pathLength = 0; + return; + } + + // A launched target is chased to where it will come down. + Vec2 chase = target.isLaunched ? landing_predict(game, target) : seen; + float standoff = (hint.rangeMin + hint.rangeMax) * GOAL_SPAN; + int goalSurface = surface_below(chase, -BOX_HEIGHT); + if (goalSurface < 0) + return; + Surface& surface = surfaces[goalSurface]; + float goalX = std::clamp(chase.x + side * standoff, surface.left, surface.right); + state.goal = {goalX, surface.y_at(goalX)}; + if (current >= 0 && !is_path_found(state, current, goalSurface, grapple_slot_get(fighter) >= 0)) + state.pathLength = 0; +} + +// Racing: head for the far end of the rightmost surface, where the finish line is, by the cheapest way there. +void Bot::race_think(Fighter& fighter, BotState& state) +{ + state.thinkTimer = BOT_DIFFICULTIES[level].thinkInterval; + state.target = -1; + int finish = 0; + for (int i = 0; i < (int)surfaces.size(); i++) + finish = surfaces[i].right > surfaces[finish].right ? i : finish; + state.goal = {surfaces[finish].right, surfaces[finish].y_at(surfaces[finish].right)}; + int current = surface_find(fighter); + if (current >= 0 && !is_path_found(state, current, finish, grapple_slot_get(fighter) >= 0)) + state.pathLength = 0; +} + +// Follows the path: walk to each link's takeoff and act there (jump, drop through or walk off), steer for the +// landing while airborne, and walk to the goal once on its surface. A bot that stops moving short of where it's +// going jumps. +void Bot::move_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +{ + Body& body = fighter.body; + float x = fighter.center_get().x; + int current = surface_find(fighter); + if (state.isGrappling) + { + grapple_steer(game, fighter, state, controls); + return; + } + + if (!body.isGrounded) + { + control_set(controls, CONTROL_JUMP, state.isJumping && body.velocity.y < 0); + control_set(controls, CONTROL_DOWN, state.isDropping && body.box.y + body.box.h < state.dropY + TILE_SIZE); + float dx = state.landing - x; + control_set(controls, dx < 0 ? CONTROL_LEFT : CONTROL_RIGHT, std::abs(dx) > ARRIVE_DISTANCE); + state.isAirborne = true; + return; + } + if (state.isAirborne) + { + state.isAirborne = false; + state.isDashed = false; + state.isJumping = false; + state.isDropping = false; + state.thinkTimer = 0; + } + + while (state.pathStep < state.pathLength && links[state.path[state.pathStep]].to == current) + state.pathStep++; + float aim = state.goal.x; + Link* link = nullptr; + if (state.pathStep < state.pathLength && links[state.path[state.pathStep]].from == current) + { + link = &links[state.path[state.pathStep]]; + aim = link->takeoff; + } + + // Walks and falls carry straight on past the takeoff towards the landing. + float dx = aim - x; + bool isArrived = std::abs(dx) <= ARRIVE_DISTANCE; + bool isCarried = link && (link->kind == LINK_FALL || link->kind == LINK_WALK); + if (isCarried) + dx = link->landing - x; + control_set(controls, dx < 0 ? CONTROL_LEFT : CONTROL_RIGHT, !isArrived || isCarried); + + state.stuckTimer = std::abs(body.velocity.x) < STUCK_SPEED && !isArrived ? state.stuckTimer + TICK : 0; + bool isStuck = state.stuckTimer > STUCK_TIME; + if (isStuck) + state.stuckTimer = 0; + + if (link) + state.landingY = surfaces[link->to].y_at(link->landing); + if (link && isArrived && link->kind == LINK_GRAPPLE) + { + state.landing = link->landing; + state.isGrappling = true; + state.grappleTimer = 0; + } + if (link && isArrived && link->kind == LINK_JUMP) + { + state.landing = link->landing; + state.isJumping = true; + control_set(controls, CONTROL_JUMP, true); + control_set(controls, link->landing < x ? CONTROL_LEFT : CONTROL_RIGHT, true); + } + if (link && isArrived && link->kind == LINK_DROP) + { + state.landing = link->landing; + state.isDropping = true; + state.dropY = surfaces[link->from].y_at(link->takeoff); + control_set(controls, CONTROL_DOWN, true); + } + if (isCarried) + state.landing = link->landing; + if (isStuck || (state.isAlert && is_threatened(game, fighter))) + { + state.landing = aim; + state.isJumping = true; + control_set(controls, CONTROL_JUMP, true); + } +} + +// Grapple climb: switch to the grapple, charge it, then fire the instant grapple at the platform above and ride the +// pull. Let go once the feet clear the platform (or the pull ends), then steer onto it like any landing. Gives up +// after a while. +void Bot::grapple_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +{ + state.grappleTimer += TICK; + int slot = grapple_slot_get(fighter); + Attack* hook = game.weapons.hook_get(fighter); + bool isAbove = fighter.body.box.y + fighter.body.box.h < state.landingY - FEET_TOLERANCE; + controls.cursor = {state.landing, state.landingY + GRAPPLE_GRIP}; + + if (slot < 0 || state.grappleTimer > GRAPPLE_TIME || (hook && (isAbove || !hook->isPulling))) + { + if (hook) + control_tap(fighter, controls, CONTROL_ATTACK); + state.isGrappling = false; + state.isAirborne = true; + return; + } + if (hook) + return; + if (fighter.slot != slot) + { + controls.slot = slot; + return; + } + control_set(controls, CONTROL_CHARGE, fighter.charge < 1); +} + +// Wings: when a flight is falling short of its landing, dash toward it once. +void Bot::wings_steer(Fighter& fighter, BotState& state, Controls& controls) +{ + Body& body = fighter.body; + float shortfall = std::abs(state.landing - fighter.center_get().x); + bool isFalling = !body.isGrounded && body.velocity.y > 0 && !state.isGrappling; + if (!fighter.is_trinket_equipped(TRINKET_WINGS) || state.isDashed || !isFalling || shortfall < DASH_SHORTFALL) + return; + state.isDashed = true; + controls.cursor = {state.landing, state.landingY - BOX_HEIGHT}; + control_tap(fighter, controls, CONTROL_JUMP); +} + +// The trinkets a bot has to drive, while airborne: +// - Iron Weights: falling onto a target that stands on solid ground (not a platform the pound would drop through), +// hold Down to fast-fall into a slam. +// - Climb Claw, when what it's after (its landing or target) is above and it's at a wall: wall-jump (level 3), kick +// off (level 2), or failing those cling, holding into the wall to keep its height. +// - Feather: falling short of a far landing, hold Up to float, starting only when a fresh press can't set off a dash +// or wall jump by accident. +// - Wings, besides saving short jumps: dash in to close the last stretch with a short-range weapon, or out of the way +// of an attack about to hit while alert. +void Bot::trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +{ + Body& body = fighter.body; + if (body.isGrounded || fighter.isLaunched || state.target < 0) + return; + Fighter& target = game.fighters[state.target]; + Vec2 center = fighter.center_get(); + Vec2 targetCenter = target.center_get(); + float feet = body.box.y + body.box.h; + bool isFalling = body.velocity.y > 0; + bool isUpHeld = fighter.controls.buttons >> CONTROL_UP & 1; + bool isDashReady = fighter.is_trinket_equipped(TRINKET_WINGS) && fighter.trinket_cooldown_get(TRINKET_WINGS) <= 0; + + float targetFeet = target.body.box.y + target.body.box.h; + bool isOnSolid = game.world.is_solid_at({targetCenter.x, targetFeet + 1}); + bool isPounding = fighter.is_trinket_equipped(TRINKET_WEIGHTS) && isFalling && target.body.isGrounded && + isOnSolid && targetFeet > feet + TILE_SIZE && std::abs(targetCenter.x - center.x) < POUND_REACH; + if (isPounding) + { + control_set(controls, CONTROL_DOWN, true); + return; + } + + int climb = fighter.trinket_get(TRINKET_CLIMBING, 0); + int wall = climb > 0 ? fighter.wall_get(game) : 0; + bool isWantingUp = state.landingY < feet - CLIMB_RISE || targetCenter.y < center.y - 2 * TILE_SIZE; + if (wall != 0 && isWantingUp) + { + Control toward = wall > 0 ? CONTROL_RIGHT : CONTROL_LEFT; + Control away = wall > 0 ? CONTROL_LEFT : CONTROL_RIGHT; + if (climb >= 3 && body.velocity.y > -CLIMB_JUMP_SPEED) + control_tap(fighter, controls, CONTROL_JUMP); + else if (climb == 2 && isFalling && fighter.trinket_cooldown_get(TRINKET_CLIMBING) <= 0) + control_tap(fighter, controls, away); + else if (isFalling) + { + controls.buttons &= ~(1 << away); + control_set(controls, toward, true); + } + return; + } + + bool isFar = std::abs(state.landing - center.x) > FEATHER_REACH; + bool isPressSafe = isUpHeld || (!isDashReady && climb < 3); + if (fighter.is_trinket_equipped(TRINKET_FEATHER) && isFalling && isFar && isPressSafe) + control_set(controls, CONTROL_UP, true); + + if (!isDashReady || !fighter.isAlive) + return; + float distance = vec2_length(targetCenter - fighter.hand_get()); + int weapon = fighter.weapon_get(); + BotHint hint = weapon >= 0 ? hint_get(fighter, fighter.slot, state.isChargePlanned) : BotHint{}; + bool isChasing = weapon >= 0 && hint.rangeMax <= DASH_SHORT_RANGE && distance > hint.rangeMax && + distance < hint.rangeMax + DASH_CHASE; + bool isDodging = state.isAlert && is_threatened(game, fighter); + if (!isChasing && !isDodging) + return; + float side = targetCenter.x < center.x ? 1 : -1; + controls.cursor = isChasing ? targetCenter : center + Vec2{side * DASH_DODGE, -DASH_DODGE}; + control_tap(fighter, controls, CONTROL_JUMP); +} + +// How long a shot launched at speed takes to fly distance. Rockets speed up from launch to top speed on the way. +static float flight_time_get(AttackKind kind, float speed, float distance) +{ + if (kind != ATTACK_ROCKET) + return distance / speed; + float top = speed * ROCKET_SPEEDUP; + float rampTime = (top - speed) / ROCKET_ACCELERATION; + float rampDistance = speed * rampTime + ROCKET_ACCELERATION * rampTime * rampTime / 2; + if (distance >= rampDistance) + return rampTime + (distance - rampDistance) / top; + return (std::sqrt(speed * speed + 2 * ROCKET_ACCELERATION * distance) - speed) / ROCKET_ACCELERATION; +} + +// Where to aim at a target seen at position moving at velocity, by the weapon's aim style and the planned attack's +// projectile flight: straight at it (instant attacks too, like a charged grapple), or ahead of it to where it will be +// when the shot arrives (raised for lobbed shots that drop, lowered to the feet for splash). +static Vec2 aim_point_get(Fighter& fighter, int weaponSlot, bool isCharged, Vec2 position, Vec2 velocity) +{ + int weapon = fighter.weaponLoadout[weaponSlot]; + AimStyle style = WEAPON_DEFS[weapon].bot.aim; + const LevelDef& level = fighter.level_get(weaponSlot); + AttackKind kind = isCharged ? WEAPON_DEFS[weapon].charged.kind : WEAPON_DEFS[weapon].normal.kind; + float speed = (isCharged ? level.charged.speed : level.normal.speed) * fighter.trinket_get(TRINKET_ACCELERATOR, 1); + bool isInstant = kind == ATTACK_HOOK && isCharged; + if (style == AIM_DIRECT || speed <= 0 || isInstant) + return position; + + // Splash shots go for the ground under the target, so only its sideways movement matters. + if (style == AIM_GROUND) + velocity.y = 0; + Vec2 hand = fighter.hand_get(); + Vec2 point = position; + float time = 0; + for (int i = 0; i < 2; i++) + { + time = flight_time_get(kind, speed, vec2_length(point - hand)); + point = position + velocity * time; + } + if (style == AIM_LOB) + point.y -= ARROW_GRAVITY * time * time / 2; + if (style == AIM_GROUND) + point.y += BOX_HEIGHT / 2; + return point; +} + +// Aims where the target was a reaction time ago, turning at the difficulty's rate, and fights with the held weapon: +// switches to the planned slot, holds attack in band once aimed, and charges while close enough to need it soon, +// firing once full, aimed and in band (or once it has waited too long). Mines detonate when the target stands inside +// one's blast. +void Bot::attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& controls) +{ + if (state.target < 0) + return; + const BotDifficulty& skill = BOT_DIFFICULTIES[level]; + Fighter& target = game.fighters[state.target]; + Vec2 seen = seen_get(state, skill.reactionTicks); + Vec2 velocity = (seen - seen_get(state, skill.reactionTicks + 1)) * (1 / TICK); + Vec2 hand = fighter.hand_get(); + if (state.slot >= 0 && state.slot != fighter.slot && fighter.charge <= 0) + controls.slot = state.slot; + + int weapon = fighter.weapon_get(); + Vec2 point = weapon >= 0 ? aim_point_get(fighter, fighter.slot, state.isChargePlanned, seen, velocity) : seen; + float turn = std::remainder(vec2_angle(point - hand) + state.aimError - state.aimAngle, 2 * PI); + state.aimAngle += std::clamp(turn, -skill.turnRate * TICK, skill.turnRate * TICK); + controls.cursor = hand + vec2_polar(state.aimAngle, std::max(BOX_WIDTH, vec2_length(point - hand))); + if (weapon < 0) + return; + + // A hook that struck lets go at once, or once a charged one has yanked the bot in to its target. + Attack* hook = game.weapons.hook_get(fighter); + if (hook) + { + if (!hook->isPulling) + control_tap(fighter, controls, CONTROL_ATTACK); + return; + } + + BotHint hint = hint_get(fighter, fighter.slot, state.isChargePlanned); + float distance = vec2_length(seen - hand); + bool isAimed = std::abs(turn) < AIM_READY; + bool isNarrow = hint.rangeMax - hint.rangeMin < STRAFE_DISTANCE * 2; + bool isInBand = + distance <= hint.rangeMax && (distance >= hint.rangeMin || !isNarrow) && is_sight_clear(game, hand, seen); + state.chargeHeld = fighter.charge >= 1 ? state.chargeHeld + TICK : 0; + + // Thrown weapons come back once they're past the target or have hung out a while, and mines go off when the + // target is inside one's blast. + for (Attack& attack : game.weapons.attacks) + { + if (attack.owner != fighter.index || attack.isDead || attack.isReturning) + continue; + AttackKind kind = attack.def()->kind; + bool isPast = vec2_length(attack.position - hand) > distance + BOOMERANG_OVERSHOOT; + if (kind == ATTACK_BOOMERANG && isPast && game.weapons.is_busy(fighter, fighter.weapon_key(fighter.slot))) + { + control_tap(fighter, controls, CONTROL_CHARGE); + return; + } + if (kind == ATTACK_FLAIL && attack.time > FLAIL_HOLD) + { + control_tap(fighter, controls, CONTROL_ATTACK); + return; + } + if (is_mine_armed(attack) && + vec2_length(attack.position - target.center_get()) < attack.stats.area * attack.scale) + control_set(controls, CONTROL_ATTACK, true); + } + + if (state.isChargePlanned) + { + bool isReady = (isInBand || state.chargeHeld > CHARGE_HOLD_MAX) && isAimed; + bool isHolding = fighter.charge < 1 ? distance < hint.rangeMax * CHARGE_START_REACH : !isReady; + control_set(controls, CONTROL_CHARGE, isHolding); + if (fighter.charge >= 1 && isReady) + state.isChargePlanned = false; + return; + } + // A Grimoire gathers wisps for a while, then lets go for a tick to send them. + AttackKind kind = WEAPON_DEFS[weapon].normal.kind; + bool isAttacking = isInBand && isAimed && kind != ATTACK_DETONATE; + state.attackHeld = isAttacking && kind == ATTACK_WISP ? state.attackHeld + TICK : 0; + if (state.attackHeld > WISP_GATHER) + { + isAttacking = false; + state.attackHeld = 0; + } + control_set(controls, CONTROL_ATTACK, isAttacking); +} + +// Racing: hacks at the nearest unbroken block in its body's way, just ahead in the way it's walking, holding attack +// with a weapon that strikes on its own (not a mine detonator, and a grapple only as a last resort). Holding off the +// stuck-jump while it chops. +void Bot::block_attack(Game& game, Fighter& fighter, BotState& state, Controls& controls) +{ + World& world = game.world; + Rect box = fighter.body.box; + bool isLeft = controls.buttons >> CONTROL_LEFT & 1; + Rect ahead = {isLeft ? box.x - BLOCK_SEEK : box.x, box.y, box.w + BLOCK_SEEK, box.h}; + Vec2 center = fighter.center_get(); + Vec2 target; + float nearest = INFINITY; + for (Dummy& dummy : world.dummies) + { + Vec2 middle = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h / 2}; + bool isWhole = dummy.tile >= 0 && world.tiles[dummy.tile] == TILE_BLOCK; + float distance = vec2_length(middle - center); + if (isWhole && is_rect_overlapping(ahead, dummy.box) && distance < nearest) + { + nearest = distance; + target = middle; + } + } + if (nearest == INFINITY) + return; + state.stuckTimer = 0; + + int best = -1; + for (int slot = 0; slot < fighter.weaponSlots; slot++) + { + int weapon = fighter.weaponLoadout[slot]; + AttackKind kind = weapon >= 0 ? WEAPON_DEFS[weapon].normal.kind : ATTACK_DETONATE; + if (kind != ATTACK_DETONATE && (best < 0 || kind != ATTACK_HOOK)) + best = slot; + } + if (best < 0) + return; + if (fighter.slot != best) + controls.slot = best; + + Attack* hook = game.weapons.hook_get(fighter); + if (hook) + { + control_tap(fighter, controls, CONTROL_ATTACK); + return; + } + controls.cursor = target; + control_set(controls, CONTROL_ATTACK, fighter.slot == best); +} + +// Debug view: surfaces as lines, links as thin lines from takeoff to landing, and each bot's remaining path in white. +void Bot::draw(Game& game) +{ + if (!isDrawn) + return; + + Renderer& renderer = game.renderer; + for (Surface& surface : surfaces) + renderer.line_draw({surface.left, surface.y_at(surface.left)}, {surface.right, surface.y_at(surface.right)}, + SURFACE_WIDTH, GRAY); + for (Link& link : links) + renderer.line_draw({link.takeoff, surfaces[link.from].y_at(link.takeoff)}, + {link.landing, surfaces[link.to].y_at(link.landing)}, LINK_WIDTH, + color_fade(GRAY, GOAL_SPAN)); + for (int i = 0; i < game.fighterCount; i++) + { + BotState& state = states[i]; + if (!game.fighters[i].isBot) + continue; + for (int step = state.pathStep; step < state.pathLength; step++) + { + Link& link = links[state.path[step]]; + renderer.line_draw({link.takeoff, surfaces[link.from].y_at(link.takeoff)}, + {link.landing, surfaces[link.to].y_at(link.landing)}, SURFACE_WIDTH, WHITE); + } + renderer.circle_draw(state.goal, SURFACE_WIDTH * 2, WHITE); + } +} diff --git a/src/bot.h b/src/bot.h new file mode 100644 index 0000000..9871314 --- /dev/null +++ b/src/bot.h @@ -0,0 +1,147 @@ +#pragma once + +#include "fighter.h" + +struct Game; + +constexpr int BOT_HISTORY = 20; +constexpr int BOT_PATH_MAX = 16; + +// How sharp a bot is: its aim error in radians, how likely it reacts to danger, how often it re-thinks, how fast its +// aim turns in radians per second, and how many ticks late it sees its target. +struct BotDifficulty +{ + float aimError = 0; + float dodgeChance = 0; + float thinkInterval = 0; + float turnRate = 0; + int reactionTicks = 0; +}; + +enum BotLevel +{ + BOT_EASY, + BOT_NORMAL, + BOT_HARD, + BOT_LEVEL_COUNT +}; + +constexpr BotDifficulty BOT_DIFFICULTIES[BOT_LEVEL_COUNT] = { + {.aimError = 0.25f, .dodgeChance = 0.15f, .thinkInterval = 0.35f, .turnRate = 4, .reactionTicks = 18}, + {.aimError = 0.12f, .dodgeChance = 0.45f, .thinkInterval = 0.2f, .turnRate = 8, .reactionTicks = 10}, + {.aimError = 0.04f, .dodgeChance = 0.85f, .thinkInterval = 0.12f, .turnRate = 16, .reactionTicks = 4}, +}; + +enum LinkKind +{ + LINK_WALK, + LINK_FALL, + LINK_DROP, + LINK_JUMP, + LINK_GRAPPLE +}; + +// A walkable run of floor, platform or ramp. Left and right bound where a fighter's centre can be while standing on +// it. Y is where feet stand at the left end, rising by slope per pixel across, within yMin and yMax. Platforms can be +// dropped through. +struct Surface +{ + float left = 0; + float right = 0; + float slope = 0; + float y = 0; + float yMax = 0; + float yMin = 0; + bool isPlatform = false; + + float y_at(float x) + { + return std::clamp(y + slope * (x - left), yMin, yMax); + } +}; + +// A way from one surface to another: walk across, fall off the end, drop through a platform, jump, or (holding a +// grapple) pull up to a platform too high to jump to. A bot heads for the takeoff x on the first surface, acts there, +// then steers for the landing x. +struct Link +{ + LinkKind kind; + float cost = 0; + float landing = 0; + float takeoff = 0; + int from = 0; + int to = 0; +}; + +// One bot's plan. Path is the links still to take, goal is where it wants to stand, and landing is where it's +// steering while airborne, onto a surface at landingY. A grapple climb aims for that surface and gives up after a +// while. A Wings dash happens once per flight. Charge held is how long a full charge has waited to be fired. A drop +// holds down until the feet pass dropY. Think counts down to the next decision. Seen is a ring of the target's recent +// positions, so the bot can react late. Aim angle turns toward the target at the difficulty's rate, off by this think's +// error. Strafe is which way it drifts while in range, and alert bots dodge this think. +struct BotState +{ + Vec2 seen[BOT_HISTORY]; + Vec2 goal; + float aimAngle = 0; + float aimError = 0; + float attackHeld = 0; + float chargeHeld = 0; + float dropY = 0; + float grappleTimer = 0; + float landing = 0; + float landingY = 0; + float stuckTimer = 0; + float thinkTimer = 0; + int path[BOT_PATH_MAX] = {}; + int pathLength = 0; + int pathStep = 0; + int seenCount = 0; + int seenHead = 0; + int strafe = 0; + int slot = -1; + int target = -1; + bool isAirborne = false; + bool isAlert = false; + bool isChargePlanned = false; + bool isDashed = false; + bool isDropping = false; + bool isGrappling = false; + bool isJumping = false; +}; + +// Writes Controls for every bot fighter from inside the simulation, so every peer computes the same bot inputs. +// The surface graph is built from each map as it loads, with links grouped by the surface they leave from. In a race, +// bots run for the finish and smash blocks in their way instead of fighting. With RPG_BOT_DEBUG set it's drawn with +// each bot's path. Level is the difficulty every bot plays at, from RPG_BOT_DIFFICULTY (0 to 2). +class Bot +{ +public: + BotState states[MAX_FIGHTERS]; + int level = BOT_NORMAL; + + void init(Game& game); + void graph_build(World& world); + void update(Game& game); + void draw(Game& game); + +private: + std::vector links; + std::vector surfaces; + std::vector linkStarts; + bool isDrawn = false; + + void surfaces_build(World& world); + void links_build(); + int surface_find(Fighter& fighter); + int surface_below(Vec2 point, float above); + bool is_path_found(BotState& state, int from, int to, bool isGrappler); + void think(Game& game, Fighter& fighter, BotState& state); + void race_think(Fighter& fighter, BotState& state); + void move_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void grapple_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void wings_steer(Fighter& fighter, BotState& state, Controls& controls); + void trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void block_attack(Game& game, Fighter& fighter, BotState& state, Controls& controls); +}; diff --git a/src/chat.cpp b/src/chat.cpp new file mode 100644 index 0000000..3b86b2f --- /dev/null +++ b/src/chat.cpp @@ -0,0 +1,384 @@ +#include "chat.h" +#include "game.h" +#include +#include + +constexpr int CHAT_LINES_MAX = 100; +// No one: a fighter with no one to talk for it (a bot, or offline, anyone but this screen's). +constexpr int NO_MEMBER = -2; +constexpr int CHAT_SHOWN = 6; +constexpr int CHAT_HISTORY = 12; +constexpr float CHAT_LINGER = 10; +constexpr float CHAT_FADE = 1; +constexpr float CHAT_WIDTH = 200; +constexpr float CHAT_PADDING = 2; +constexpr float CHAT_GAP = 4; +constexpr float CHAT_BACKING = 0.55f; +constexpr float BUBBLE_WIDTH = 120; +constexpr float BUBBLE_IN = 0.25f; +constexpr float BUBBLE_HANG = 5; +constexpr float BUBBLE_OUT = 0.6f; +constexpr float BUBBLE_RISE = 6; +constexpr float BUBBLE_ABOVE = 26; +constexpr float BUBBLE_MATCH_ABOVE = 34; +constexpr float DOTS_STEP = 0.35f; + +static float seconds_get() +{ + return SDL_GetTicks() / 1000.0f; +} + +// Splits text into lines no wider than width, at spaces where it can (a word longer than a line is cut). +static std::vector wrap(Game& game, const std::string& text, float width) +{ + std::vector lines; + std::string line; + std::string word; + auto word_place = [&]() { + std::string tried = line.empty() ? word : line + " " + word; + if (game.text.size_get(tried.c_str()).x <= width) + { + line = tried; + return; + } + if (!line.empty()) + lines.push_back(line); + line.clear(); + for (char c : word) + { + if (game.text.size_get((line + c).c_str()).x > width && !line.empty()) + { + lines.push_back(line); + line.clear(); + } + line += c; + } + }; + for (char c : text) + { + if (c != ' ') + { + word += c; + continue; + } + if (!word.empty()) + word_place(); + word.clear(); + } + if (!word.empty()) + word_place(); + if (!line.empty()) + lines.push_back(line); + return lines; +} + +void Chat::clear() +{ + lines.clear(); + players_clear(); +} + +// Forgets everything about who's in the game (bubbles, typing, message rates), keeping the log, as this screen goes +// into or out of a lobby. +void Chat::players_clear() +{ + bubbles.clear(); + typing.clear(); + recent.clear(); +} + +// Who a fighter's chat belongs to: its lobby member online; offline, this screen's own fighter is the one that talks +// (under this screen's member id, -1). +int Chat::member_get(Game& game, int fighter) +{ + if (game.session.isActive) + return game.match.owners[fighter]; + return fighter == game.local ? game.net.selfId : NO_MEMBER; +} + +void Chat::system(const std::string& text) +{ + lines.push_back({.text = text, .color = WHITE, .time = seconds_get()}); + if ((int)lines.size() > CHAT_LINES_MAX) + lines.erase(lines.begin()); +} + +// A player's message: into the log in their colour, over their head, with a sound. Messages from a player coming too +// fast (more than a handful in a few seconds) are dropped. +void Chat::say(Game& game, int member, const std::string& text) +{ + constexpr int BURST = 5; + constexpr float BURST_SECONDS = 4; + float now = seconds_get(); + std::vector& times = recent[member]; + std::erase_if(times, [&](float time) { return now - time > BURST_SECONDS; }); + if ((int)times.size() >= BURST) + return; + times.push_back(now); + + bool isOnline = game.session.isActive; + int fighter = isOnline ? game.session.fighter_get(game, member) : game.local; + Color color = fighter >= 0 ? game.color_get(fighter, true) : WHITE; + std::string name = isOnline ? game.session.name_get(game, member) : std::string(game.settings.name); + lines.push_back({.text = name + ": " + text, .color = color, .time = now}); + if ((int)lines.size() > CHAT_LINES_MAX) + lines.erase(lines.begin()); + bubbles[member].push_back({.text = text, .time = now}); + typing[member] = false; + game.audio.play(SOUND_CHAT); +} + +// Each frame: the Chat control opens the box (in a lobby, with the menu closed); while it's open, typing goes into it, +// Enter sends and Escape drops it. Bubbles that have faded go. +void Chat::update(Game& game) +{ + for (std::string& notice : game.net.notices) + system(notice); + game.net.notices.clear(); + Input& input = game.input; + if (!isOpen && !game.menu.isOpen && input.is_key_control_pressed(CONTROL_CHAT)) + open(game); + else if (isOpen) + { + for (int i = 0; i < input.erases && !draft.empty(); i++) + draft.pop_back(); + for (const char* c = input.typed; *c && (int)draft.size() < CHAT_MESSAGE_MAX; c++) + if (*c >= ' ' && *c <= '~') + draft += *c; + if (input.isSubmitted) + { + if (!input.isCancelled) + submit(game); + close(game); + } + else if (game.menu.isOpen) + close(game); + } + float now = seconds_get(); + for (auto& [member, list] : bubbles) + std::erase_if(list, [&](ChatBubble& bubble) { return now - bubble.time > BUBBLE_HANG + BUBBLE_OUT; }); +} + +void Chat::open(Game& game) +{ + isOpen = true; + draft.clear(); + SDL_StartTextInput(game.window); + game.session.typing_send(game, true); +} + +void Chat::close(Game& game) +{ + isOpen = false; + draft.clear(); + SDL_StopTextInput(game.window); + game.session.typing_send(game, false); +} + +void Chat::submit(Game& game) +{ + std::string text = text_clean(draft, CHAT_MESSAGE_MAX); + if (text.empty()) + return; + if (text[0] == '/') + command(game, text); + else + game.session.chat_send(game, text); +} + +// A command line split into words, where a "quoted part" counts as one (quotes taken off). +static std::vector arguments_split(const std::string& text) +{ + std::vector words; + std::string word; + bool isQuoted = false; + bool isWord = false; + for (char c : text) + { + if (c == '"') + { + isQuoted = !isQuoted; + isWord = true; + continue; + } + if (c == ' ' && !isQuoted) + { + if (isWord) + words.push_back(word); + word.clear(); + isWord = false; + continue; + } + word += c; + isWord = true; + } + if (isWord) + words.push_back(word); + if (words.empty()) + words.push_back(""); + return words; +} + +// /name and /color change this player's (as Customize does), /kick "" "" (the host +// only) puts a player out of the lobby, and /help lists them. Answers are system lines only this player sees. +void Chat::command(Game& game, const std::string& text) +{ + std::vector words = arguments_split(text); + std::string name = words[0]; + for (char& c : name) + c = (char)SDL_tolower(c); + // A name or colour is the rest of the line (quotes around it are fine too). + size_t space = text.find(' '); + std::string argument = space == std::string::npos ? "" : text_clean(text.substr(space + 1), PLAYER_NAME_MAX); + if (argument.size() >= 2 && argument.front() == '"' && argument.back() == '"') + argument = text_clean(argument.substr(1, argument.size() - 2), PLAYER_NAME_MAX); + if (name == "/help") + { + system("/name : Changes your name (once every 30 seconds in a lobby)."); + system("/color : Changes your colour. Colours: Red, Blue, Yellow, Green, Orange, Purple, Cyan, Pink, " + "Lime, Teal, Magenta, Sky, Peach, Lavender, Brown, Silver."); + system("/kick \"\" \"\": Removes a player from the lobby (host only)."); + return; + } + if (name == "/kick") + { + if (words.size() < 2) + return system("Usage: /kick \"\" \"\" (the reason is optional)"); + game.session.kick(game, words[1], words.size() > 2 ? words[2] : ""); + return; + } + if (name == "/name") + { + if (argument.empty()) + return system("Usage: /name "); + if (int wait = game.session.rename_wait_get()) + return system("You can change your name again in " + seconds_say(wait) + "."); + SDL_strlcpy(game.settings.name, argument.c_str(), sizeof(game.settings.name)); + game.settings.changed(); + // Online, the session announces it to everyone (see Session::profile_watch). + if (!game.session.isActive) + system("Your name is now " + argument + "."); + return; + } + if (name == "/color" || name == "/colour") + { + for (int color = 0; color < COLOR_COUNT; color++) + { + if (SDL_strcasecmp(argument.c_str(), COLOR_NAMES[color]) != 0) + continue; + int own = game.session.fighter_get(game, game.net.selfId); + for (int i = 0; i < game.fighterCount && game.session.isActive; i++) + if (i != own && game.match.colors[i] == color) + return system(std::string(COLOR_NAMES[color]) + " is already taken by another player."); + game.settings.color = color; + game.settings.changed(); + system("Your colour is now " + std::string(COLOR_NAMES[color]) + "."); + return; + } + return system("Usage: /color . Type /help for the colour list."); + } + system("Unknown command. Type /help for a list of commands."); +} + +// Where the log goes: the bottom right, just above the other players' windows when they're showing. +Rect Chat::log_area_get(Game& game) +{ + float bottom = CANVAS_HEIGHT - HOTBAR_MARGIN; + MatchState state = (MatchState)game.match.state; + if (state != MATCH_PRACTICE && state != MATCH_COUNTDOWN) + { + RivalLayout rivals = game.ui.rivals_layout(game); + for (int k = 0; k < rivals.count; k++) + bottom = std::min(bottom, rivals.cards[k].y - CHAT_GAP); + } + return {CANVAS_WIDTH - HOTBAR_MARGIN - CHAT_WIDTH, 0, CHAT_WIDTH, bottom}; +} + +// The log, newest at the bottom, each line on a dark backing. Closed, the last few lines show until they've been up +// a while, then fade; open, more show, over the box being typed in. +void Chat::draw(Game& game) +{ + Renderer& renderer = game.renderer; + Rect area = log_area_get(game); + float lineHeight = LINE_HEIGHT; + float y = area.h; + float now = seconds_get(); + if (isOpen) + { + y -= lineHeight + CHAT_PADDING * 2; + Rect box = {area.x, y, area.w, lineHeight + CHAT_PADDING * 2}; + game.ui.panel_draw(renderer, box, game.ui.tint); + std::string shown = "> " + draft; + bool isCaretShown = (int)(now * 2) % 2 == 0; + // Only the end of a long draft shows. + while (game.text.size_get((shown + "_").c_str()).x > area.w - CHAT_PADDING * 4 && shown.size() > 2) + shown.erase(2, 1); + game.text.draw(renderer, (shown + (isCaretShown ? "_" : "")).c_str(), + {.color = game.ui.tint, .position = {box.x + CHAT_PADDING * 2, box.y + CHAT_PADDING}}); + y -= CHAT_GAP; + } + + int shown = 0; + int limit = isOpen ? CHAT_HISTORY : CHAT_SHOWN; + for (int i = (int)lines.size() - 1; i >= 0 && shown < limit; i--, shown++) + { + ChatLine& line = lines[i]; + float age = now - line.time; + float alpha = isOpen ? 1 : std::clamp((CHAT_LINGER - age) / CHAT_FADE, 0.0f, 1.0f); + if (alpha <= 0) + break; + std::vector parts = wrap(game, line.text, area.w - CHAT_PADDING * 2); + for (int part = (int)parts.size() - 1; part >= 0; part--) + { + y -= lineHeight; + renderer.rect_draw({area.x, y, area.w, lineHeight}, color_fade(BLACK, CHAT_BACKING * alpha)); + game.text.draw(renderer, parts[part].c_str(), + {.color = color_fade(line.color, alpha), .position = {area.x + CHAT_PADDING, y}}); + } + } +} + +// Over each player in the lobby: a "..." while they're typing, and their latest messages, newest lowest, each rising +// in, hanging a few seconds and fading out. Drawn in the world, with the fighters. +void Chat::overhead_draw(Game& game) +{ + float now = seconds_get(); + float above = game.match.state == MATCH_PRACTICE ? BUBBLE_ABOVE : BUBBLE_MATCH_ABOVE; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + int member = member_get(game, i); + if (!fighter.isAlive || member == NO_MEMBER) + continue; + Color color = game.color_get(i, true); + float centerX = fighter.body.box.x + fighter.body.box.w / 2; + float y = fighter.body.box.y - above; + bool isTyping = member == game.net.selfId ? isOpen : typing[member]; + if (isTyping) + { + int dots = 1 + (int)(now / DOTS_STEP) % 3; + std::string text(dots, '.'); + Vec2 size = game.text.size_get("..."); + game.text.draw(game.renderer, text.c_str(), + {.color = color, .position = {std::round(centerX - size.x / 2), std::round(y)}}); + y -= LINE_HEIGHT; + } + std::vector& list = bubbles[member]; + for (int b = (int)list.size() - 1; b >= 0; b--) + { + float age = now - list[b].time; + float in = std::min(1.0f, age / BUBBLE_IN); + float alpha = in * std::clamp(1 - (age - BUBBLE_HANG) / BUBBLE_OUT, 0.0f, 1.0f); + std::vector parts = wrap(game, list[b].text, BUBBLE_WIDTH); + float rise = (1 - in) * BUBBLE_RISE; + for (int part = (int)parts.size() - 1; part >= 0; part--) + { + Vec2 size = game.text.size_get(parts[part].c_str()); + game.text.draw(game.renderer, parts[part].c_str(), + {.color = color_fade(color, alpha), + .position = {std::round(centerX - size.x / 2), std::round(y + rise)}}); + y -= LINE_HEIGHT; + } + } + } +} diff --git a/src/chat.h b/src/chat.h new file mode 100644 index 0000000..8bcf112 --- /dev/null +++ b/src/chat.h @@ -0,0 +1,59 @@ +#pragma once + +#include "common.h" +#include "protocol.h" +#include +#include +#include + +struct Game; + +// A line of the chat log: a player's message, in their colour, or a system one (someone joining, leaving or renaming, +// or a command's answer), in white. Time is when it came, in seconds. +struct ChatLine +{ + std::string text; + Color color = WHITE; + float time = 0; +}; + +// A message over a player's head: it rises in, hangs a while, then fades away. Time is when it came, in seconds. +struct ChatBubble +{ + std::string text; + float time = 0; +}; + +// Talking, anywhere (alone, it's a log for this screen's own lines and commands). The Chat control opens a box at the +// bottom of the log; Enter sends what's typed (a message, or a command: /name, /color, /help) and Escape drops it. The +// log sits at the bottom right, above the player windows: closed, its last lines show and fade away after a while; +// open, more of it shows. Each message also rises over its player's head, and a player with the box open has a "..." +// over theirs. Messages reach everyone through the session (never the game itself), arriving cleaned (see text_clean) +// and with a limit on how fast each player's are taken. +class Chat +{ +public: + bool isOpen = false; + std::string draft; + std::vector lines; + std::map> bubbles; + std::map typing; + + void update(Game& game); + void system(const std::string& text); + void say(Game& game, int member, const std::string& text); + void clear(); + void players_clear(); + void draw(Game& game); + void overhead_draw(Game& game); + +private: + std::map> recent; + + void open(Game& game); + void close(Game& game); + void submit(Game& game); + void command(Game& game, const std::string& text); + Rect log_area_get(Game& game); + int member_get(Game& game, int fighter); +}; diff --git a/src/common.h b/src/common.h new file mode 100644 index 0000000..0a4ee9c --- /dev/null +++ b/src/common.h @@ -0,0 +1,217 @@ +#pragma once + +#include "dmath.h" +#include +#include +#include +#include + +constexpr const char* GAME_ID = "smack-a-brother"; +constexpr const char* GAME_NAME = "Smack a Brother"; +constexpr int CANVAS_HEIGHT = 360; +constexpr int CANVAS_WIDTH = 640; +constexpr int TILE_SIZE = 16; +constexpr float GRAVITY = 1000.0f; +constexpr float PI = 3.14159265f; +constexpr float TICK = 1.0f / 60.0f; + +struct Vec2 +{ + float x = 0; + float y = 0; +}; + +struct Rect +{ + float x = 0; + float y = 0; + float w = 0; + float h = 0; +}; + +struct Color +{ + float r = 1; + float g = 1; + float b = 1; + float a = 1; +}; + +constexpr Color BLACK = {0, 0, 0, 1}; +constexpr Color GRAY = {128 / 255.0f, 128 / 255.0f, 128 / 255.0f, 1}; +constexpr Color WHITE = {1, 1, 1, 1}; + +inline Vec2 operator+(Vec2 a, Vec2 b) +{ + return {a.x + b.x, a.y + b.y}; +} + +inline Vec2 operator-(Vec2 a, Vec2 b) +{ + return {a.x - b.x, a.y - b.y}; +} + +inline Vec2 operator*(Vec2 v, float scale) +{ + return {v.x * scale, v.y * scale}; +} + +inline float vec2_dot(Vec2 a, Vec2 b) +{ + return a.x * b.x + a.y * b.y; +} + +inline float vec2_length(Vec2 v) +{ + return std::sqrt(v.x * v.x + v.y * v.y); +} + +inline Vec2 vec2_normalize(Vec2 v) +{ + float length = vec2_length(v); + return length > 0 ? v * (1 / length) : Vec2{1, 0}; +} + +inline float vec2_angle(Vec2 v) +{ + return math_atan2(v.y, v.x); +} + +inline Vec2 vec2_polar(float angle, float length) +{ + return {math_cos(angle) * length, math_sin(angle) * length}; +} + +inline Vec2 vec2_lerp(Vec2 from, Vec2 to, float t) +{ + return from + (to - from) * t; +} + +// A 2D affine transform taking x to (a·x + c·y + x0, b·x + d·y + y0). +struct Affine +{ + float a = 1; + float b = 0; + float c = 0; + float d = 1; + float x0 = 0; + float y0 = 0; +}; + +inline Affine operator*(Affine m, Affine n) +{ + return {m.a * n.a + m.c * n.b, m.b * n.a + m.d * n.b, m.a * n.c + m.c * n.d, + m.b * n.c + m.d * n.d, m.a * n.x0 + m.c * n.y0 + m.x0, m.b * n.x0 + m.d * n.y0 + m.y0}; +} + +inline Vec2 operator*(Affine m, Vec2 v) +{ + return {m.a * v.x + m.c * v.y + m.x0, m.b * v.x + m.d * v.y + m.y0}; +} + +// Moves to position, turns by angle and scales, about origin. +inline Affine affine_get(Vec2 position, float angle, Vec2 scale, Vec2 origin) +{ + float cosine = math_cos(angle); + float sine = math_sin(angle); + Affine m = {cosine * scale.x, sine * scale.x, -sine * scale.y, cosine * scale.y, position.x, position.y}; + return m * Affine{1, 0, 0, 1, -origin.x, -origin.y}; +} + +// Blends angles the short way round. +inline float angle_lerp(float from, float to, float t) +{ + return from + std::remainder(to - from, 2 * PI) * t; +} + +// Cosmetic randomness: particles, flicker, shake. Gameplay draws from Rng instead. +inline float random_get(float min, float max) +{ + return min + SDL_randf() * (max - min); +} + +constexpr int RNG_FRACTION_BITS = 24; +constexpr int RNG_SHIFT_A = 13; +constexpr int RNG_SHIFT_B = 17; +constexpr int RNG_SHIFT_C = 5; +constexpr Uint32 RNG_SEED = 0x9E3779B9; + +// A small seeded xorshift generator for gameplay, saved with the simulation so a replay draws the same numbers. +struct Rng +{ + Uint32 state = RNG_SEED; + + float random_get(float min, float max) + { + state ^= state << RNG_SHIFT_A; + state ^= state >> RNG_SHIFT_B; + state ^= state << RNG_SHIFT_C; + float unit = (state >> (32 - RNG_FRACTION_BITS)) / (float)(1 << RNG_FRACTION_BITS); + return min + unit * (max - min); + } +}; + +inline bool is_point_in_rect(Vec2 point, Rect rect) +{ + return point.x >= rect.x && point.y >= rect.y && point.x < rect.x + rect.w && point.y < rect.y + rect.h; +} + +inline bool is_rect_overlapping(Rect a, Rect b) +{ + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; +} + +// Darkens a colour by amount, keeping its alpha. +inline Color color_shade(Color color, float amount) +{ + return {color.r * amount, color.g * amount, color.b * amount, color.a}; +} + +inline Color color_fade(Color color, float alpha) +{ + return {color.r, color.g, color.b, color.a * alpha}; +} + +// A list of up to N items held in place, without the heap: game state kept in one, unlike in a std::vector, is plain +// bytes that mean the same on every platform (see Fighter). Pushing past N drops the item. +template struct FixedList +{ + T items[N] = {}; + int count = 0; + + void clear() + { + count = 0; + } + void push_back(const T& item) + { + if (count < N) + items[count++] = item; + } + T* begin() + { + return items; + } + T* end() + { + return items + count; + } + int size() const + { + return count; + } + template void erase_if(Predicate predicate) + { + int kept = 0; + for (int i = 0; i < count; i++) + if (!predicate(items[i])) + items[kept++] = items[i]; + count = kept; + } +}; + +// Absolute path to a file inside the resources folder next to the executable. +inline std::string path_get(const char* relative) +{ + return std::string(SDL_GetBasePath()) + "resources/" + relative; +} diff --git a/src/dmath.h b/src/dmath.h new file mode 100644 index 0000000..5d4d627 --- /dev/null +++ b/src/dmath.h @@ -0,0 +1,232 @@ +#pragma once + +#include +#include +#include + +// Deterministic maths: the same answer to the bit on every platform and compiler, so desktop and web builds simulate +// identically. The C library's sin, cos, atan2, pow and friends differ between platforms in their last bits, so +// these replace them everywhere (tools/determinism_check.sh fails if any come back). They use only + - * / and +// operations IEEE 754 defines exactly (floor, frexp, ldexp), working in double and rounding to float at the end. That +// relies on floats being evaluated as floats (no x87) and on nothing being fused into a multiply-add, which the +// build turns off. +#if FLT_EVAL_METHOD != 0 +#error "Deterministic maths needs float arithmetic evaluated at float precision (FLT_EVAL_METHOD 0)" +#endif + +namespace dmath +{ +constexpr double PI = 3.141592653589793; +constexpr double HALF_PI = 1.5707963267948966; +constexpr double TWO_OVER_PI = 0.6366197723675814; +// Half pi split in two, the first part with enough trailing zero bits that k times it is exact (fdlibm's pio2_1). +constexpr double HALF_PI_HIGH = 1.5707963267341256; +constexpr double HALF_PI_LOW = 6.077100506506192e-11; +constexpr double SQRT3 = 1.7320508075688772; +constexpr double TAN_PI_12 = 0.2679491924311227; +constexpr double SQRT_HALF = 0.7071067811865476; +constexpr double LN2 = 0.6931471805599453; +constexpr double INV_LN2 = 1.4426950408889634; +constexpr double EXP2_MAX = 128; +constexpr double EXP2_MIN = -150; +constexpr double REDUCE_MAX = 1e9; + +// sin of r in [-pi/4, pi/4], Taylor to r^15. +inline double sin_near(double r) +{ + double r2 = r * r; + return r + r * r2 * + (-1.0 / 6 + + r2 * (1.0 / 120 + + r2 * (-1.0 / 5040 + + r2 * (1.0 / 362880 + r2 * (-1.0 / 39916800 + + r2 * (1.0 / 6227020800 + r2 * (-1.0 / 1307674368000))))))); +} + +// cos of r in [-pi/4, pi/4], Taylor to r^16. +inline double cos_near(double r) +{ + double r2 = r * r; + return 1 + + r2 * (-1.0 / 2 + + r2 * (1.0 / 24 + + r2 * (-1.0 / 720 + + r2 * (1.0 / 40320 + r2 * (-1.0 / 3628800 + + r2 * (1.0 / 479001600 + r2 * (-1.0 / 87178291200 + + r2 * (1.0 / 20922789888000)))))))); +} + +// Splits x into a quarter turn count (its last two bits returned) and what's left, in [-pi/4, pi/4]. +inline int quadrant_get(double x, double& rest) +{ + double turns = std::floor(x * TWO_OVER_PI + 0.5); + rest = (x - turns * HALF_PI_HIGH) - turns * HALF_PI_LOW; + return (int)((int64_t)turns & 3); +} + +inline double sin_double(double x) +{ + if (!(std::fabs(x) < REDUCE_MAX)) + return NAN; + double r; + switch (quadrant_get(x, r)) + { + case 0: + return sin_near(r); + case 1: + return cos_near(r); + case 2: + return -sin_near(r); + default: + return -cos_near(r); + } +} + +inline double cos_double(double x) +{ + if (!(std::fabs(x) < REDUCE_MAX)) + return NAN; + double r; + switch (quadrant_get(x, r)) + { + case 0: + return cos_near(r); + case 1: + return -sin_near(r); + case 2: + return -cos_near(r); + default: + return sin_near(r); + } +} + +// atan of t >= 0: folded into [0, 1] (atan t = pi/2 - atan 1/t), then near zero (atan t = pi/6 + atan of +// (t sqrt3 - 1) / (sqrt3 + t)), where its Taylor series to t^29 is plenty. +inline double atan_positive(double t) +{ + bool isInverted = t > 1; + if (isInverted) + t = 1 / t; + double base = 0; + if (t > TAN_PI_12) + { + t = (t * SQRT3 - 1) / (SQRT3 + t); + base = PI / 6; + } + double t2 = t * t; + double series = 0; + for (int k = 14; k >= 0; k--) + series = (k % 2 ? -1.0 : 1.0) / (2 * k + 1) + t2 * series; + double angle = base + t * series; + return isInverted ? HALF_PI - angle : angle; +} + +inline double atan2_double(double y, double x) +{ + if (std::isnan(x) || std::isnan(y)) + return NAN; + if (x == 0 && y == 0) + return 0; + double angle = x == 0 ? HALF_PI : atan_positive(std::fabs(y) / std::fabs(x)); + if (x < 0) + angle = PI - angle; + return y < 0 ? -angle : angle; +} + +// log2 of x > 0: its exponent, plus the log of its mantissa (scaled into [sqrt 1/2, sqrt 2)) as 2 atanh s with +// s = (m - 1) / (m + 1), by its series to s^23. +inline double log2_double(double x) +{ + if (std::isnan(x) || x < 0) + return NAN; + if (x == 0) + return -INFINITY; + if (std::isinf(x)) + return INFINITY; + int exponent = 0; + double mantissa = std::frexp(x, &exponent); + if (mantissa < SQRT_HALF) + { + mantissa *= 2; + exponent--; + } + double s = (mantissa - 1) / (mantissa + 1); + double s2 = s * s; + double series = 0; + for (int k = 11; k >= 0; k--) + series = 1.0 / (2 * k + 1) + s2 * series; + return exponent + 2 * s * series * INV_LN2; +} + +// 2 to the y: a whole power (exact, by ldexp) times e to the leftover's share, by its Taylor series to z^17. +inline double exp2_double(double y) +{ + if (std::isnan(y)) + return NAN; + if (y > EXP2_MAX) + return INFINITY; + if (y < EXP2_MIN) + return 0; + double whole = std::floor(y + 0.5); + double z = (y - whole) * LN2; + double series = 0; + for (int k = 17; k >= 1; k--) + series = (1 + z * series / k); + return std::ldexp(series, (int)whole); +} + +// x to the y, for the cases the game uses: any y for x >= 0, whole y for negative x. +inline double pow_double(double x, double y) +{ + if (y == 0 || x == 1) + return 1; + if (std::isnan(x) || std::isnan(y)) + return NAN; + if (x == 0) + return y > 0 ? 0 : INFINITY; + if (x < 0) + { + if (std::floor(y) != y) + return NAN; + double size = exp2_double(y * log2_double(-x)); + return std::fmod(std::fabs(y), 2) == 1 ? -size : size; + } + return exp2_double(y * log2_double(x)); +} +} // namespace dmath + +// Part way from a to b. Written out because std::lerp's formula differs between standard libraries. +inline float math_lerp(float a, float b, float t) +{ + return a + (b - a) * t; +} + +inline float math_sin(float x) +{ + return (float)dmath::sin_double(x); +} + +inline float math_cos(float x) +{ + return (float)dmath::cos_double(x); +} + +inline float math_tan(float x) +{ + return (float)(dmath::sin_double(x) / dmath::cos_double(x)); +} + +inline float math_atan2(float y, float x) +{ + return (float)dmath::atan2_double(y, x); +} + +inline float math_log2(float x) +{ + return (float)dmath::log2_double(x); +} + +inline float math_pow(float x, float y) +{ + return (float)dmath::pow_double(x, y); +} diff --git a/src/effects.cpp b/src/effects.cpp new file mode 100644 index 0000000..29fd876 --- /dev/null +++ b/src/effects.cpp @@ -0,0 +1,256 @@ +#include "effects.h" +#include "game.h" +#include + +constexpr int AFTERIMAGE_INTERVAL = 2; +constexpr size_t EFFECTS_MAX = 1000; +constexpr int NUMBER_LENGTH = 16; +constexpr float NUMBER_MERGE_TIME = 0.25f; +constexpr float AFTERIMAGE_ALPHA = 0.6f; +constexpr float DPS_WINDOW = 3; +constexpr float FLASH_CROSS = 0.5f; +constexpr float FLASH_LENGTH = 16; +constexpr float FLASH_LIFE = 0.05f; +constexpr float FLASH_WIDTH = 6; +constexpr float AFTERIMAGE_LIFE = 0.12f; +constexpr float NUMBER_GAP = 8; +constexpr float POPUP_FADE = 0.4f; +constexpr float POPUP_JITTER_SCALE = 2; +constexpr float POPUP_JITTER_START = 4; +constexpr float POPUP_GRAVITY = 500; +constexpr float POPUP_JUMP = 180; +constexpr float POPUP_LIFE = 0.9f; +constexpr float POPUP_SCALE = 2; +constexpr float POPUP_SCALE_MAX = 5; +constexpr float POPUP_SCALE_START = 2.5f; +constexpr float POPUP_SCALE_STEP = 1.5f; +constexpr float POPUP_SPREAD = 40; +constexpr float PARTICLE_OUTLINE = 1; +constexpr float PARTICLE_RANDOM_MIN = 0.4f; +constexpr float SHAKE_DECAY = 2.5f; +constexpr float SHAKE_LIMIT = 12; +constexpr float SHAKE_MAX = 10; +constexpr float SHAKE_TRAUMA_MAX = 3; + +// Adds to one of the effect lists, dropping its oldest once it holds EFFECTS_MAX, so a busy fight can't flood the +// screen (or the renderer) with leftovers. +template static void effect_add(std::vector& list, const T& effect) +{ + if (list.size() >= EFFECTS_MAX) + list.erase(list.begin()); + list.push_back(effect); +} + +// Sprays particles at position, rotated by angle on top of the burst's own angle. +void Effects::burst(Burst burst, Vec2 position, float angle, Color color) +{ + if (isMuted) + return; + for (int i = 0; i < burst.count; i++) + { + float direction = angle + burst.angle + random_get(-burst.spread, burst.spread); + float speed = burst.speed * random_get(PARTICLE_RANDOM_MIN, 1); + float life = burst.life * random_get(PARTICLE_RANDOM_MIN, 1); + effect_add(particles, Particle{color, position, vec2_polar(direction, speed), burst.fill, burst.gravity, life, + life, burst.size}); + } +} + +// Callers may ask every tick. Copies are only kept every few ticks so trails stay spaced out. +void Effects::afterimage_spawn(Pose pose) +{ + if (isMuted) + return; + if (ticks % AFTERIMAGE_INTERVAL == 0) + effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE}); +} + +// Drawn before the player and attacks so trails sit behind what made them. +void Effects::afterimage_draw(Game& game) +{ + for (Afterimage& afterimage : afterimages) + { + Pose pose = afterimage.pose; + pose.placement.color = color_fade(pose.placement.color, AFTERIMAGE_ALPHA * afterimage.life / AFTERIMAGE_LIFE); + pose.draw(game.renderer); + } +} + +// Damage numbers are popups whose whole-number scale and shake both grow with the logarithm of the damage. +// Numbers take the attacker's colour and start just above the hit (bigger ones higher), so they don't cover it. A hit +// on a target whose last number from the same attacker appeared moments ago folds into it, respawning it with the +// total. +void Effects::number_spawn(Vec2 position, int value, int target, Color color) +{ + if (isMuted) + return; + for (int i = (int)popups.size() - 1; i >= 0; i--) + { + Popup& popup = popups[i]; + bool isSameColor = popup.color.r == color.r && popup.color.g == color.g && popup.color.b == color.b; + if (popup.value <= 0 || popup.target != target || !isSameColor || POPUP_LIFE - popup.life > NUMBER_MERGE_TIME) + continue; + value += popup.value; + popups.erase(popups.begin() + i); + break; + } + + // Its bottom edge starts a small gap above the hit, however big the number is drawn. + char text[NUMBER_LENGTH]; + SDL_snprintf(text, sizeof(text), "%d", value); + float magnitude = math_log2((float)std::max(1, value)); + float scale = std::clamp(1 + std::floor((magnitude - POPUP_SCALE_START) / POPUP_SCALE_STEP), 1.0f, POPUP_SCALE_MAX); + popup_spawn(position - Vec2{0, LINE_HEIGHT * scale / 2 + NUMBER_GAP}, text, color); + popups.back().scale = scale; + popups.back().jitter = std::max(0.0f, std::floor(magnitude - POPUP_JITTER_START)) * POPUP_JITTER_SCALE; + popups.back().target = target; + popups.back().value = value; +} + +void Effects::popup_spawn(Vec2 position, const char* text, Color color) +{ + if (isMuted) + return; + Popup popup = { + .color = color, + .position = position, + .velocity = {random_get(-POPUP_SPREAD, POPUP_SPREAD), -POPUP_JUMP}, + .jitter = 0, + .life = POPUP_LIFE, + .scale = POPUP_SCALE, + }; + SDL_strlcpy(popup.text, text, sizeof(popup.text)); + effect_add(popups, popup); +} + +void Effects::flash_spawn(Vec2 position, float angle, float size, Color color) +{ + if (isMuted) + return; + effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size}); +} + +void Effects::dps_record(int amount) +{ + if (isMuted) + return; + hits.push_back({ticks * TICK, amount}); +} + +// Damage dealt over the last few seconds, averaged per second. +float Effects::dps_get() +{ + int total = 0; + for (Hit& hit : hits) + total += hit.amount; + return total / DPS_WINDOW; +} + +void Effects::shake_add(float amount) +{ + if (isMuted) + return; + shake = std::min(SHAKE_TRAUMA_MAX, shake + amount); +} + +// Shake grows with the square of its trauma so small hits stay subtle, up to a limit in pixels. Big trauma +// still lasts longer at that limit. +Vec2 Effects::shake_get() +{ + float strength = std::min(SHAKE_LIMIT, shake * shake * SHAKE_MAX); + return {random_get(-strength, strength), random_get(-strength, strength)}; +} + +void Effects::update() +{ + shake = std::max(0.0f, shake - SHAKE_DECAY * TICK); + ticks++; + std::erase_if(hits, [&](Hit& hit) { return ticks * TICK - hit.time > DPS_WINDOW; }); + + for (Flash& flash : flashes) + flash.life -= TICK; + std::erase_if(flashes, [](Flash& flash) { return flash.life <= 0; }); + + for (Afterimage& afterimage : afterimages) + afterimage.life -= TICK; + std::erase_if(afterimages, [](Afterimage& afterimage) { return afterimage.life <= 0; }); + + for (Particle& particle : particles) + { + particle.velocity.y += particle.gravity * TICK; + particle.position = particle.position + particle.velocity * TICK; + particle.life -= TICK; + } + std::erase_if(particles, [](Particle& particle) { return particle.life <= 0; }); + + for (Popup& popup : popups) + { + popup.velocity.y += POPUP_GRAVITY * TICK; + popup.position = popup.position + popup.velocity * TICK; + popup.life -= TICK; + } + std::erase_if(popups, [](Popup& popup) { return popup.life <= 0; }); +} + +// Particles and popups have no stored history, so they step back along their velocity +// by however much of the tick drawing is behind. +void Effects::draw(Game& game) +{ + Renderer& renderer = game.renderer; + + float behind = (1 - game.alpha) * TICK; + + // Each flash is a long streak along the shot crossed by a short one, shrinking as it fades. + for (Flash& flash : flashes) + { + float fade = flash.life / FLASH_LIFE; + float length = FLASH_LENGTH * flash.size * fade; + float width = FLASH_WIDTH * flash.size * fade; + Vec2 along = vec2_polar(flash.angle, length / 2); + Vec2 across = vec2_polar(flash.angle + PI / 2, length * FLASH_CROSS / 2); + renderer.line_draw(flash.position - along * 0.2f, flash.position + along * 1.8f, width, flash.color); + renderer.line_draw(flash.position - across, flash.position + across, width * FLASH_CROSS, flash.color); + } + + // Each particle shrinks and fades out. The outline is four strips so the fading core never shows it through. + for (Particle& particle : particles) + { + float fade = particle.life / particle.lifeMax; + float size = std::ceil(particle.size * fade); + float outer = size + PARTICLE_OUTLINE * 2; + Vec2 position = particle.position - particle.velocity * behind; + Vec2 corner = {std::round(position.x - size / 2), std::round(position.y - size / 2)}; + Vec2 edge = corner - Vec2{PARTICLE_OUTLINE, PARTICLE_OUTLINE}; + Color color = color_fade(particle.color, fade); + renderer.rect_draw({edge.x, edge.y, outer, PARTICLE_OUTLINE}, color); + renderer.rect_draw({edge.x, corner.y + size, outer, PARTICLE_OUTLINE}, color); + renderer.rect_draw({edge.x, corner.y, PARTICLE_OUTLINE, size}, color); + renderer.rect_draw({corner.x + size, corner.y, PARTICLE_OUTLINE, size}, color); + renderer.rect_draw({corner.x, corner.y, size, size}, + color_fade(color_shade(particle.color, particle.fill), fade)); + } + + for (Popup& popup : popups) + { + Vec2 size = game.text.size_get(popup.text, FONT_SMALL, popup.scale); + float jitter = popup.jitter * popup.life / POPUP_LIFE; + Vec2 shake = {random_get(-jitter, jitter), random_get(-jitter, jitter)}; + Vec2 position = popup.position - popup.velocity * behind + shake; + Vec2 corner = {std::round(position.x - size.x / 2), std::round(position.y - size.y / 2)}; + float alpha = std::min(1.0f, popup.life / POPUP_FADE); + game.text.draw( + renderer, popup.text, + {.color = color_fade(popup.color, alpha), .position = corner, .scale = {popup.scale, popup.scale}}); + } +} + +// Everything showing goes, as when this screen starts showing another game. +void Effects::clear() +{ + afterimages.clear(); + flashes.clear(); + hits.clear(); + particles.clear(); + popups.clear(); + shake = 0; +} diff --git a/src/effects.h b/src/effects.h new file mode 100644 index 0000000..9ecf95f --- /dev/null +++ b/src/effects.h @@ -0,0 +1,102 @@ +#pragma once + +#include "anm2.h" +#include + +struct Game; + +// Describes a spray of square particles, fanned around angle by spread. Fill is how far their colour takes over their +// black middle. +struct Burst +{ + float angle = 0; + float fill = 0; + float gravity = 0; + float life = 0.4f; + float size = 3; + float speed = 100; + float spread = PI; + int count = 8; +}; + +struct Particle +{ + Color color; + Vec2 position; + Vec2 velocity; + float fill; + float gravity; + float life; + float lifeMax; + float size; +}; + +// Text that hops up from where it appears and fades, like damage numbers. Jitter shakes it, in pixels. +// Damage numbers also keep their value and which target they're over, so quick hits can add up. +struct Popup +{ + Color color; + Vec2 position; + Vec2 velocity; + char text[16]; + float jitter; + float life; + float scale; + int target; + int value; +}; + +// Damage dealt at a moment in game time, for the DPS readout. +struct Hit +{ + float time = 0; + int amount = 0; +}; + +// A muzzle flash: a brief white star at the barrel, pointing along the shot. +struct Flash +{ + Color color; + Vec2 position; + float angle = 0; + float life = 0; + float size = 0; +}; + +// A fading copy of a sprite left behind by something moving fast. +struct Afterimage +{ + Pose pose; + float life = 0; +}; + +// Particles, afterimages, text popups, screen shake and the damage-per-second tally. While muted (during a +// rollback replay) it ignores new requests. +class Effects +{ +public: + bool isMuted = false; + + void burst(Burst burst, Vec2 position, float angle, Color color); + void afterimage_spawn(Pose pose); + void flash_spawn(Vec2 position, float angle, float size, Color color); + void afterimage_draw(Game& game); + void number_spawn(Vec2 position, int value, int target, Color color); + void dps_record(int amount); + float dps_get(); + void popup_spawn(Vec2 position, const char* text, Color color); + void shake_add(float amount); + Vec2 shake_get(); + void update(); + void draw(Game& game); + void clear(); + +private: + std::vector afterimages; + std::vector flashes; + std::vector hits; + std::vector particles; + std::vector popups; + float shake = 0; + int ticks = 0; +}; diff --git a/src/fighter.cpp b/src/fighter.cpp new file mode 100644 index 0000000..1823256 --- /dev/null +++ b/src/fighter.cpp @@ -0,0 +1,1015 @@ +#include "fighter.h" +#include "game.h" +#include +#include + +constexpr int BLINK_MILLISECONDS = 80; +constexpr float AIR_ACCELERATION = 400; +constexpr float BOUNCE_SHAKE = 0.0005f; +constexpr float SLAM_DAMAGE = 0.06f; +constexpr float KNOCK_BASE = 60; +constexpr float KNOCK_LIFT = 0.35f; +constexpr float KNOCK_MAX = 4500; +constexpr float KNOCK_PER_DAMAGE = 30; +constexpr float LAUNCH_START_SPEED = 300; +constexpr float CHECKPOINT_ALPHA = 0.3f; +constexpr float CHECKPOINT_HEIGHT = 24; +constexpr float POUND_RING_ALPHA = 0.25f; + +constexpr float AFTERIMAGE_SPEED = 440; +constexpr float AIR_FRICTION = 600; +constexpr float CLING_SPEED = 60; +constexpr float COYOTE_TIME = 0.1f; +constexpr float DASH_COOLDOWN = 0.8f; +constexpr float DASH_TIME = 0.12f; +constexpr float DEATH_SHAKE = 1.5f; +constexpr float FACING_DEADZONE = 4; +constexpr float FAST_FALL_GRAVITY = 2; +constexpr float FLASH_DECAY = 8; +constexpr float FAST_FALL_SPEED = 700; +constexpr float GROUND_ACCELERATION = 500; +constexpr float GROUND_FRICTION = 3000; +constexpr float JUMP_BUFFER = 0.1f; +constexpr float JUMP_RELEASE_SPEED = 160; +constexpr float KICK_COOLDOWN = 1.2f; +constexpr float LAUNCH_DRAG = 160; +constexpr float LAND_SPEED = 150; +constexpr float MAX_FALL = 520; +constexpr float ROPE_MIN = 24; +constexpr float SLIDE_DUST_INTERVAL = 0.08f; +constexpr float STEP_SPEED = 40; +constexpr float SWING_ACCELERATION = 500; +constexpr float SWING_SPEED_MAX = 420; +constexpr float POUND_AREAS[LEVEL_COUNT] = {30, 40, 50}; +constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50}; +constexpr float POUND_SCALE_MAX = 3; +constexpr float POUND_SCALE_MIN = 0.3f; +constexpr float POUND_SPEED = 700; +constexpr Vec2 HAND_OFFSET = {4, 2}; +constexpr Vec2 OFFHAND_OFFSET = {-3, 4}; +constexpr float WALL_IMPACT_SPEED = 200; +constexpr float WALL_LIFT = 0.25f; +constexpr float WALL_MEMORY = 0.6f; +constexpr float WALL_REFLECT = 1.2f; +constexpr float WALL_POINT_HEIGHT = 0.4f; +constexpr Vec2 KICK_VELOCITY = {220, -330}; +constexpr Vec2 WALL_JUMP_VELOCITY = {180, -JUMP_SPEED}; +constexpr float CROUCH_HEIGHT = 0.75f; +constexpr float CROUCH_DAMP = 0.5f; +constexpr float KICK_POSE_TIME = 0.2f; +constexpr float SPIN_GRAVITY = 0.5f; +constexpr float CRAWL_SPEED = 0.35f; +constexpr float CRAWL_STRIDE = 5; +constexpr float DOWNHILL_BOOST = 0.6f; +constexpr float MOMENTUM_DRAG = 400; +constexpr float ROLL_RADIUS = 10; +constexpr float SLIDE_FRICTION = 150; +constexpr float SLIDE_MAX = 700; +constexpr float SLIDE_MIN = 60; +constexpr float TUMBLE_RATE = 0.012f; +constexpr float WALK_STRIDE = 7; +// Footsteps per animation frame walked or crawled: one per four-frame walk cycle, one per half crawl cycle. +constexpr float WALK_STEP_FRAMES = 4; +constexpr float CRAWL_STEP_FRAMES = 2; + +// Animations drawn facing their own way, picked by the way the fighter moves (right, then left). The rest are +// mirrored to face left. +constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"}; +constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"}; +constexpr Burst LAND_DUST = { + .angle = -PI / 2, .gravity = 150, .life = 0.35f, .speed = 90, .spread = PI * 0.45f, .count = 10}; +constexpr Burst DEATH_BURST = {.gravity = 300, .life = 0.8f, .size = 4, .speed = 260, .count = 40}; +constexpr Burst BOUNCE_DUST = {.gravity = 200, .life = 0.4f, .speed = 160, .count = 10}; +constexpr Burst KICK_DUST = {.gravity = 200, .life = 0.35f, .speed = 120, .spread = PI / 3, .count = 8}; +constexpr Burst READY_BURST = {.life = 0.4f, .size = 2, .speed = 120, .count = 12}; +constexpr Burst SLIDE_DUST = {.gravity = -40, .life = 0.3f, .size = 2, .speed = 30, .spread = PI / 3, .count = 1}; +constexpr Burst STEP_DUST = { + .angle = -PI / 2, .gravity = 150, .life = 0.25f, .size = 2, .speed = 40, .spread = PI / 2, .count = 2}; + +// Makes this fighter number, with empty loadouts, standing at its spawn. +void Fighter::init(Game& game, int number) +{ + index = number; + std::fill(std::begin(weaponLoadout), std::end(weaponLoadout), -1); + std::fill(std::begin(trinketLoadout), std::end(trinketLoadout), -1); + state_reset(game, 1); +} + +// Uses the first weapons and trinkets slots of each loadout, emptying the rest, and holds a slot in use. +void Fighter::slots_set(int weapons, int trinkets) +{ + weaponSlots = weapons; + trinketSlots = trinkets; + std::fill(weaponLoadout + weapons, std::end(weaponLoadout), -1); + std::fill(trinketLoadout + trinkets, std::end(trinketLoadout), -1); + slot = std::min(slot, weapons - 1); +} + +// Back at its spawn and still: no velocity, nothing charging, no echoes, not launched or stuck in hitstop. +void Fighter::position_reset(Game& game) +{ + position_set(game, game.world.spawns[index]); +} + +// Puts the fighter still with its feet at a point, nothing charging. +void Fighter::position_set(Game& game, Vec2 spawn) +{ + charge_cancel(game); + body = {.box = {spawn.x - BOX_WIDTH / 2, spawn.y - BOX_HEIGHT, BOX_WIDTH, BOX_HEIGHT}}; + cornerPrevious = {body.box.x, body.box.y}; + safe = spawn; + echoes.clear(); + hitstop = 0; + isLaunched = false; +} + +// Back at its spawn, alive, at full health and still, holding its first slot, with nothing charging or cooling +// down. Loadouts and levels stay as they are. +void Fighter::state_reset(Game& game, float maxHealth) +{ + position_reset(game); + std::fill(std::begin(cooldowns), std::end(cooldowns), 0.0f); + flash = 0; + health = maxHealth; + offSlot = -1; + slot = 0; + isAlive = true; +} + +// Loses health with a flash and hitstop, and gets knocked away from where the hit came from (and a little upward), +// up to a speed that grows with the damage. Knocks top up rather than stack, so rapid small hits can't juggle. A hard +// enough knock launches it. Shock Absorber softens it. Running out of health kills it in a burst, and its attacks +// vanish with it. +void Fighter::damage_take(Game& game, Damage damage) +{ + // Races and the sandbox never cost health. + bool isRacing = game.match.is_racing(); + health -= isRacing || game.match.state == MATCH_PRACTICE ? 0 : damage.amount; + flash = 1; + hitstop = std::max(hitstop, damage.hitstop); + + Vec2 away = center_get() - damage.source; + Vec2 direction = vec2_normalize(vec2_normalize(vec2_length(away) > 1 ? away : Vec2{0, -1}) + Vec2{0, -KNOCK_LIFT}); + float knock = std::min(KNOCK_MAX, KNOCK_BASE + damage.amount * KNOCK_PER_DAMAGE) * damage.knock * + (isCrouching ? CROUCH_DAMP : 1) * (isRacing ? RACE_KNOCK : 1) * knock_absorb_get(); + float along = vec2_dot(body.velocity, direction); + impulse_add(direction * std::max(0.0f, knock - along), false); + launcher = damage.owner; + isLaunched |= knock >= LAUNCH_START_SPEED; + if (health > 0) + return; + + isAlive = false; + charge_cancel(game); + for (Attack& attack : game.weapons.attacks) + attack.isDead |= attack.owner == index; + game.audio.play(SOUND_DEATH); + game.effects.burst(DEATH_BURST, center_get(), 0, game.color_get(index)); + game.effects.shake_add(DEATH_SHAKE); +} + +void Fighter::update(Game& game) +{ + if (!isAlive) + return; + cornerPrevious = {body.box.x, body.box.y}; + flash = std::max(0.0f, flash - FLASH_DECAY * TICK); + shockCooldown -= TICK; + if (hitstop > 0) + { + hitstop -= TICK; + return; + } + + for (float& cooldown : cooldowns) + cooldown = std::max(0.0f, cooldown - TICK); + kickTime += TICK; + Attack* hook = game.weapons.hook_get(*this); + slot_update(game); + // A slide keeps rolling through the air for as long as S is held, like off a kicker or jumping mid-slide. + bool wasCrouching = isCrouching; + bool isTucked = is_down(CONTROL_DOWN) && !isLaunched; + isCrouching = body.isGrounded && isTucked; + if (isCrouching != wasCrouching && body.isGrounded && !isSliding) + game.audio.play(isCrouching ? SOUND_DUCK : SOUND_UNDUCK); + bool isSlideKept = isSliding && (std::abs(body.velocity.x) > SLIDE_MIN || !body.isGrounded); + isSliding = isTucked && (body.isGrounded ? body.slope != 0 || isSlideKept : isSliding); + size_update(); + isSpinning = game.weapons.is_spinning(*this); + run_update(hook); + body.isDropping = is_down(CONTROL_DOWN); + + bool isJumpHeld = is_down(CONTROL_JUMP) || (!hook && is_down(CONTROL_UP)); + bool isJumpPressed = is_pressed(CONTROL_JUMP) || (!hook && is_pressed(CONTROL_UP)); + jumpBuffer = isJumpPressed ? JUMP_BUFFER : jumpBuffer - TICK; + coyoteTimer = body.isGrounded ? COYOTE_TIME : coyoteTimer - TICK; + Vec2 feet = {center_get().x, body.box.y + body.box.h}; + climb_update(game); + + if (jumpBuffer > 0 && coyoteTimer > 0 && !isLaunched) + { + body.velocity.y = -JUMP_SPEED; + jumpBuffer = 0; + coyoteTimer = 0; + isJumping = true; + game.audio.play(SOUND_JUMP); + game.effects.burst(STEP_DUST, feet, 0, game.color_get(index)); + } + dash_update(game, isJumpPressed); + + isJumping &= body.velocity.y < 0 && !hook; + if (isJumping && !isJumpHeld && body.velocity.y < -JUMP_RELEASE_SPEED) + body.velocity.y = -JUMP_RELEASE_SPEED; + + if (hook) + rope_update(*hook); + + bool wasGrounded = body.isGrounded; + bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS); + float fallSpeed = body.velocity.y; + float runSpeed = body.velocity.x; + game.world.obstacles = game.weapons.solids_get(*this); + game.world.body_move(body); + game.world.obstacles.clear(); + if (isLaunched) + bounce_resolve(game, {runSpeed, fallSpeed}); + safe_update(game); + + wallMemory -= TICK; + bool isWallHit = body.velocity.x == 0 && std::abs(runSpeed) > WALL_IMPACT_SPEED; + if (isWallHit && is_trinket_equipped(TRINKET_CLIMBING)) + { + wallSpeed = std::abs(runSpeed); + wallMemory = WALL_MEMORY; + } + if (wallMemory <= 0) + wallSpeed = 0; + if (vec2_length(body.velocity) > AFTERIMAGE_SPEED || dashTimer > 0) + game.effects.afterimage_spawn(pose_get(game)); + feet = {center_get().x, body.box.y + body.box.h}; + + if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED) + { + game.audio.play(SOUND_LAND); + game.effects.burst(LAND_DUST, feet, 0, game.color_get(index)); + } + // Iron Weights slam: the harder the landing, the harder the blast. Damage follows the landing speed, and the + // radius its square root. + if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED && isPounding) + { + float impact = std::clamp(fallSpeed / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX); + game.weapons.blast_spawn(game, {.position = feet, + .area = POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact), + .damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact, + .owner = index, + .sound = SOUND_POUND}); + } + + // A kick holds its pose briefly, and a launch tumbles faster the faster it flies. + kickPose -= TICK; + float spin = body.velocity.x < 0 ? -1 : 1; + tumble = isLaunched ? tumble + spin * vec2_length(body.velocity) * TUMBLE_RATE * TICK : 0; + // The ball rolls without slipping: on the ground it spins with its speed along the surface, and in the air it + // keeps the spin it left with. + float rollBefore = roll; + if (body.isGrounded) + rollSpin = body.velocity.x * std::sqrt(1 + body.slope * body.slope) / (ROLL_RADIUS * scale); + roll = isSliding ? roll + rollSpin * TICK : 0; + + // Footsteps only while walking on purpose or crawling, and a roll bumps once every half turn. Steps go by ground + // covered, in time with the legs (whose animation steps a frame per stride), so they quicken with speed; the first + // lands as the walk starts. + bool isMoving = is_down(CONTROL_LEFT) || is_down(CONTROL_RIGHT); + bool isWalking = + body.isGrounded && !isCrouching && !isLaunched && isMoving && std::abs(body.velocity.x) > STEP_SPEED; + if (isWalking || is_crawling()) + { + stepDistance -= std::abs(body.velocity.x) * TICK; + if (stepDistance <= 0) + { + stepDistance += isWalking ? WALK_STRIDE * WALK_STEP_FRAMES : CRAWL_STRIDE * CRAWL_STEP_FRAMES; + game.audio.play(isWalking ? SOUND_STEP : SOUND_CRAWL); + if (isWalking) + game.effects.burst(STEP_DUST, feet, 0, game.color_get(index)); + } + } + else + stepDistance = 0; + if (isSliding && body.isGrounded && std::floor(roll / PI) != std::floor(rollBefore / PI)) + game.audio.play(SOUND_ROLL); + + // Facing follows the cursor's side of the body, holding still when it's nearly straight above or below, so the + // hand (which sits on the facing side) can't flip back and forth under it. + float cursorX = controls.cursor.x - center_get().x; + if (std::abs(cursorX) > FACING_DEADZONE) + isFacingLeft = cursorX < 0; + aim = vec2_normalize(controls.cursor - hand_get()); + charge_update(game); + buttonsPrevious = controls.buttons; +} + +// Horizontal control and gravity. Holding S in the air falls faster, holding W floats with the Feather, and a spinning +// flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer only to the slope. Crouching +// on flat ground waddles along slowly. +// Airborne on a rope, moving left and right pumps the swing instead. +// Above run speed in the air, launch momentum bleeds off slowly unless steered against. Launched by a hit, there's no +// control at all, just gravity and the knock dying down. +void Fighter::run_update(Attack* hook) +{ + float direction = (float)is_down(CONTROL_RIGHT) - (float)is_down(CONTROL_LEFT); + float speed = std::abs(body.velocity.x); + float sign = body.velocity.x < 0 ? -1 : 1; + float boots = trinket_get(TRINKET_BOOTS, 1); + float runSpeed = RUN_SPEED * boots * (1 + trinket_get(TRINKET_SPEEDOMETER, 0)) * (isCrouching ? CRAWL_SPEED : 1); + if (dashTimer > 0) + return; + if (isLaunched) + { + body.velocity = + body.velocity * (1 - KNOCK_DECAY * TICK) + Vec2{0, GRAVITY * (isSpinning ? SPIN_GRAVITY : 1) * TICK}; + return; + } + + bool isAirborne = !body.isGrounded && !hook; + bool isFastFalling = isAirborne && is_down(CONTROL_DOWN) && !isSliding; + bool isFloating = isAirborne && is_down(CONTROL_UP); + float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY : 1); + gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 1; + gravity *= isSpinning ? SPIN_GRAVITY : 1; + float maxFall = isFastFalling ? FAST_FALL_SPEED * trinket_get(TRINKET_WEIGHTS, 1) : MAX_FALL; + body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall); + + // Sliding: the slope pulls it downhill and little else slows it. + if (isSliding) + { + float slope = body.slope; + body.velocity.x += GRAVITY * slope / (1 + slope * slope) * TICK; + float slowed = std::max(0.0f, std::abs(body.velocity.x) - SLIDE_FRICTION * TICK); + body.velocity.x = std::clamp(std::copysign(slowed, body.velocity.x), -SLIDE_MAX, SLIDE_MAX); + return; + } + + if (hook && !body.isGrounded) + { + if (direction * body.velocity.x <= 0 || speed < SWING_SPEED_MAX) + body.velocity.x += direction * SWING_ACCELERATION * TICK; + return; + } + + // On a slope, the top speed drops climbing and rises descending, with the angle. + float slope = body.isGrounded ? body.slope : 0; + if (slope != 0 && direction != 0) + { + float cosine = 1 / std::sqrt(1 + slope * slope); + bool isUphill = direction * slope < 0; + runSpeed *= isUphill ? cosine : 1 + DOWNHILL_BOOST * std::abs(slope) * cosine; + } + + // Faster than running, momentum bleeds off slowly unless steered against (or let go of on the ground). + if (speed > runSpeed && direction * body.velocity.x >= 0 && (direction != 0 || !body.isGrounded)) + { + float drag = body.isGrounded ? MOMENTUM_DRAG : LAUNCH_DRAG; + body.velocity.x = sign * std::max(runSpeed, speed - drag * TICK); + return; + } + + float acceleration = (body.isGrounded ? GROUND_ACCELERATION : AIR_ACCELERATION) * boots; + float friction = body.isGrounded ? GROUND_FRICTION : AIR_FRICTION; + bool isTurning = direction * body.velocity.x < 0; + float rate = (direction == 0 ? friction : acceleration + (isTurning ? friction : 0)) * TICK; + body.velocity.x = std::clamp(direction * runSpeed, body.velocity.x - rate, body.velocity.x + rate); +} + +// Up and Down reel the rope, or a charged hook yanks it in until short (slower under Shock Absorber). When the next +// step would leave the rope's reach, the step is pulled back onto the circle, so outward motion becomes swing and +// reeling in drags the player along. +void Fighter::rope_update(Attack& hook) +{ + float reel = (float)is_down(CONTROL_DOWN) - (float)is_down(CONTROL_UP); + reel *= WEAPON_DEFS[hook.weapon].levels[hook.key >= 0 ? slot_level_get(hook.key) : 0].reel; + if (hook.isPulling) + reel = -hook.stats.speed * trinket_get(TRINKET_ABSORBER, 1); + hook.length = std::clamp(hook.length + reel * TICK, ROPE_MIN, hook.stats.range); + hook.isPulling &= hook.length > ROPE_MIN; + + Vec2 center = center_get(); + Vec2 offset = center + body.velocity * TICK - hook.position; + if (vec2_length(offset) <= hook.length) + return; + + Vec2 next = hook.position + vec2_normalize(offset) * hook.length; + body.velocity = (next - center) * (1 / TICK); +} + +// The controls' slot picks a weapon slot, wrapping around, and the level buttons level the weapon copy in it. The +// weapon switched away from becomes the off-hand weapon for Dual Wield (a copy of the same weapon counts when weapons +// stack), and a thrown flail ball is dropped. +void Fighter::slot_update(Game& game) +{ + int previous = slot; + if (controls.slot != -1) + slot = (controls.slot % weaponSlots + weaponSlots) % weaponSlots; + + int held = weapon_get(); + if (slot != previous) + game.audio.play(SOUND_SELECT); + if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot)) + { + offSlot = previous; + game.weapons.attacks_release(*this, ATTACK_FLAIL); + } + + int step = (int)is_pressed(CONTROL_LEVEL_UP) - (int)is_pressed(CONTROL_LEVEL_DOWN); + if (step != 0 && held >= 0 && game.match.state == MATCH_PRACTICE) + game.ui.level_step(game, weaponSlotLevels[slot], step); +} + +// Shrinker, Embiggener and crouching: resizes the box around the feet to the fighter's current size. +void Fighter::size_update() +{ + scale = trinket_get(TRINKET_SHRINKER, 1) * trinket_get(TRINKET_EMBIGGENER, 1); + float height = BOX_HEIGHT * scale * (isCrouching || isSliding ? CROUCH_HEIGHT : 1); + Vec2 feet = {center_get().x, body.box.y + body.box.h}; + body.box = {feet.x - BOX_WIDTH * scale / 2, feet.y - height, BOX_WIDTH * scale, height}; +} + +// Wings: a jump press in the air that nothing else used (no ground, coyote or wall jump) dashes toward the +// cursor, adding a push on top of the current velocity and holding it briefly without gravity. Stacked copies don't +// add speed (the best copy's counts) but each holds a dash: the cooldown builds up a dash's worth per dash, and dashing +// works while there's a dash's worth left to build up before all of them are spent. +void Fighter::dash_update(Game& game, bool isJumpPressed) +{ + float speed = trinket_best_get(TRINKET_WINGS, 0); + dashTimer -= TICK; + dashCooldown = std::max(0.0f, dashCooldown - TICK); + + bool isUnused = isJumpPressed && jumpBuffer > 0 && !body.isGrounded && coyoteTimer <= 0; + int dashes = trinket_count(TRINKET_WINGS); + if (speed <= 0 || !isUnused || dashCooldown > DASH_COOLDOWN * (dashes - 1) || isLaunched) + return; + + jumpBuffer = 0; + burst_add(aim * speed); + dashCooldown += DASH_COOLDOWN; + game.audio.play(SOUND_DASH); + game.effects.burst(STEP_DUST, center_get(), 0, game.color_get(index)); +} + +// Climb Claw, by level. 1: pushing into a wall slows the slide down it, trailing dust. 2: pressing away kicks +// off, on a cooldown so it can't climb forever. 3: Jump wall jumps freely. +void Fighter::climb_update(Game& game) +{ + int level = trinket_get(TRINKET_CLIMBING, 0); + int wall = wall_get(game); + kickTimer -= TICK; + slideWall = 0; + if (level == 0 || wall == 0 || body.isGrounded || isLaunched) + return; + + float direction = (float)is_down(CONTROL_RIGHT) - (float)is_down(CONTROL_LEFT); + slideTimer -= TICK; + if (direction == wall && body.velocity.y > 0) + { + body.velocity.y = std::min(body.velocity.y, CLING_SPEED); + slideWall = wall; + if (slideTimer <= 0) + game.effects.burst(SLIDE_DUST, wall_point_get(wall), wall > 0 ? PI : 0, game.color_get(index)); + slideTimer = slideTimer <= 0 ? SLIDE_DUST_INTERVAL : slideTimer; + } + + Control away = wall > 0 ? CONTROL_LEFT : CONTROL_RIGHT; + if (level >= 2 && kickTimer <= 0 && is_pressed(away)) + { + wall_push(game, wall, KICK_VELOCITY); + kickTimer = KICK_COOLDOWN; + isJumping = false; + } + if (level >= 3 && jumpBuffer > 0) + { + wall_push(game, wall, WALL_JUMP_VELOCITY); + jumpBuffer = 0; + isJumping = true; + } +} + +// Pushes off the wall with at least the base velocity. A hard hit into the wall in the last moment is reflected +// back out, a little stronger, with some of it turned into extra lift. +void Fighter::wall_push(Game& game, int wall, Vec2 base) +{ + float outward = std::max(base.x, wallSpeed * WALL_REFLECT); + body.velocity = {-wall * outward, base.y - wallSpeed * WALL_LIFT}; + wallSpeed = 0; + kickPose = KICK_POSE_TIME; + game.audio.play(SOUND_JUMP); + game.effects.burst(KICK_DUST, wall_point_get(wall), wall > 0 ? PI : 0, game.color_get(index)); +} + +// Launched fighters bounce off whatever stopped them, keeping most of their speed, with a thud, dust and shake that +// grow with the impact. Hard impacts slam them for damage, credited to their launcher. The launch ends once they've +// slowed down. +void Fighter::bounce_resolve(Game& game, Vec2 before) +{ + float impact = 0; + Vec2 normal = body.normal; + float into = vec2_dot(before, normal); + if (into < -BOUNCE_MIN) + { + body.velocity = before - normal * (into * (1 + BOUNCE_KEEP)); + impact = -into; + } + else + { + if (body.velocity.x == 0 && std::abs(before.x) > BOUNCE_MIN) + { + body.velocity.x = -before.x * BOUNCE_KEEP; + impact = std::abs(before.x); + } + if (body.velocity.y == 0 && std::abs(before.y) > BOUNCE_MIN) + { + body.velocity.y = -before.y * BOUNCE_KEEP; + impact = std::max(impact, std::abs(before.y)); + } + } + if (impact > 0) + { + game.audio.play(SOUND_BOUNCE); + game.effects.burst(BOUNCE_DUST, center_get(), 0, game.color_get(index)); + game.effects.shake_add(impact * BOUNCE_SHAKE); + } + int slam = (impact - SLAM_SPEED) * SLAM_DAMAGE; + if (slam > 0) + game.weapons.damage_deal(game, {.point = center_get(), + .source = center_get(), + .knock = 0, + .amount = slam, + .owner = launcher, + .target = index, + .sound = SOUND_HIT_BLUNT_HEAVY}); + isLaunched = vec2_length(body.velocity) >= LAUNCH_END_SPEED; +} + +// Remembers where the feet last stood with solid support under both edges, and puts the fighter back there, still and +// in a puff, when it touches a pit. +void Fighter::safe_update(Game& game) +{ + World& world = game.world; + Rect box = body.box; + float feet = box.y + box.h; + auto isSupported = [&](float x) { + Tile tile = world.tile_at({x, feet + 1}); + return tile == TILE_SOLID || tile == TILE_PLATFORM; + }; + if (body.isGrounded && isSupported(box.x) && isSupported(box.x + box.w - 1)) + safe = {box.x + box.w / 2, feet}; + if (!world.is_touching(box, TILE_PIT)) + return; + + game.effects.burst(LAND_DUST, center_get(), 0, game.color_get(index)); + game.audio.play(SOUND_FALL); + charge_cancel(game); + body.box.x = safe.x - box.w / 2; + body.box.y = safe.y - box.h; + body.velocity = {}; + cornerPrevious = {body.box.x, body.box.y}; + isLaunched = false; + game.effects.burst(LAND_DUST, center_get(), 0, game.color_get(index)); +} + +// Where the player touches the wall on the given side, around hand height. +Vec2 Fighter::wall_point_get(int wall) +{ + Rect box = body.box; + return {wall > 0 ? box.x + box.w : box.x, box.y + box.h * WALL_POINT_HEIGHT}; +} + +// Which side a wall touches the player on: -1 left, 1 right, 0 neither. +int Fighter::wall_get(Game& game) +{ + Rect box = body.box; + float middle = box.y + box.h / 2; + float low = box.y + box.h - 2; + auto isWall = [&](float x) { + return game.world.is_solid_at({x, middle}) || + (game.world.tile_at({x, low}) == TILE_SLOPE && game.world.is_solid_at({x, low})); + }; + if (isWall(box.x - 1)) + return -1; + if (isWall(box.x + box.w + 1)) + return 1; + return 0; +} + +// Attack fires normal attacks, repeating while held, and letting it go sends any gathered Grimoire wisps. Charge +// charges and fires on release, falling back to a normal attack if let go early. Charge lives on the player so it +// survives weapon swaps. A fresh press of either lets go of a grapple, keeping the swing's momentum. Charge calls the +// boomerangs back once they're all out, and either reels in a flail. +void Fighter::charge_update(Game& game) +{ + Audio& audio = game.audio; + Attack* hook = game.weapons.hook_get(*this); + bool isLeftPressed = is_pressed(CONTROL_ATTACK); + bool isRightPressed = is_pressed(CONTROL_CHARGE); + if (is_released(CONTROL_ATTACK)) + game.weapons.wisps_release(*this); + + if (hook && (isLeftPressed || isRightPressed)) + { + game.weapons.attacks_release(*this, ATTACK_HOOK); + isFireBlocked = isLeftPressed; + return; + } + if (game.weapons.is_recalled(game, *this)) + { + isFireBlocked = isLeftPressed; + return; + } + + isFireBlocked &= is_down(CONTROL_ATTACK); + if (is_down(CONTROL_ATTACK) && !isFireBlocked) + game.weapons.fire(game, *this, 0); + + if (isRightPressed && weapon_get() >= 0) + isCharging = true; + + if (isCharging && is_down(CONTROL_CHARGE) && charge < 1) + { + float previous = charge; + charge = std::min(1.0f, charge + TICK / CHARGE_TIME * trinket_get(TRINKET_DRINK, 1)); + if (previous < CHARGE_VISIBLE && charge >= CHARGE_VISIBLE) + chargeVoice = audio.play(SOUND_CHARGE); + if (charge >= 1) + { + audio.stop(chargeVoice); + chargeVoice = -1; + audio.play(SOUND_READY); + game.effects.burst(READY_BURST, hand_get(), 0, game.color_get(index)); + } + } + + if (isCharging && is_released(CONTROL_CHARGE)) + { + game.weapons.fire(game, *this, charge); + audio.stop(chargeVoice); + chargeVoice = -1; + charge = 0; + isCharging = false; + } +} + +// Draws the fighter between its weapons, all shaking and filling in with the bonus its attacks would get right now. +// Behind it, in a race, a glow rising from the ground it would respawn on (its own only). Over it, the reach of an Iron +// Weights slam about to land. The local fighter also sees where its weapon would fire. +void Fighter::draw(Game& game) +{ + if (!isAlive) + return; + Renderer& renderer = game.renderer; + Color color = game.color_get(index); + bool isLocal = index == game.local && game.devices[index] != DEVICE_NONE; + if (isLocal && game.match.is_racing()) + for (int row = 0; row < CHECKPOINT_HEIGHT; row++) + renderer.rect_draw({std::round(safe.x - BOX_WIDTH / 2), std::round(safe.y) - row - 1, BOX_WIDTH, 1}, + color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT))); + + float fill = game.weapons.buff_get(*this); + Vec2 camera = renderer.camera; + renderer.camera = camera + game.weapons.jitter_get(fill); + Pose pose = pose_get(game); + pose.placement.flash = std::max(pose.placement.flash, fill); + game.weapons.offhand_draw(game, *this); + pose.draw(renderer); + game.weapons.held_draw(game, *this); + renderer.camera = camera; + + float pound = pound_area_get(); + if (pound > 0) + renderer.ring_draw({center_get().x, body.box.y + body.box.h}, pound, color_fade(color, POUND_RING_ALPHA)); + if (isLocal && !game.menu.isOpen && game.settings.isAimShown) + game.weapons.trajectory_draw(game, *this); +} + +// How much of a trinket's own cooldown is left, from 1 just used to 0 ready. Wings dashes and Climb Claw kicks +// have one, and every other trinket is always 0. +float Fighter::trinket_cooldown_get(int trinket) +{ + if (trinket == TRINKET_WINGS) + { + int spare = std::max(0, trinket_count(TRINKET_WINGS) - 1); + return std::clamp((dashCooldown - DASH_COOLDOWN * spare) / DASH_COOLDOWN, 0.0f, 1.0f); + } + if (trinket == TRINKET_CLIMBING) + return std::max(0.0f, kickTimer) / KICK_COOLDOWN; + return 0; +} + +// The radius an Iron Weights slam would blast if the fighter, pounding, landed at its current speed, or 0 if it isn't +// pounding down fast enough to slam at all. +float Fighter::pound_area_get() +{ + bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS); + if (!isPounding || body.isGrounded || body.velocity.y <= LAND_SPEED) + return 0; + float impact = std::clamp(body.velocity.y / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX); + return POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact); +} + +// Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports). +void Fighter::teleport_to(Vec2 centre) +{ + body.box.x = centre.x - body.box.w / 2; + body.box.y = centre.y - body.box.h / 2; + cornerPrevious = {body.box.x, body.box.y}; +} + +// An outside push, like a blast, recoil or a lunge. It counts as a launch, so releasing jump won't cut it short. +// Crouching halves it (knockback, already halved before it gets here, isn't damped again). +void Fighter::impulse_add(Vec2 impulse, bool isDamped) +{ + body.velocity = body.velocity + impulse * (isDamped && isCrouching ? CROUCH_DAMP : 1); + isJumping = false; +} + +// A push held briefly without gravity or friction, so it carries even along the ground. Used by dashes and lunges. +void Fighter::burst_add(Vec2 impulse) +{ + impulse_add(impulse); + dashTimer = DASH_TIME; +} + +// Drops any charge in progress and silences its sound. +void Fighter::charge_cancel(Game& game) +{ + game.audio.stop(chargeVoice); + chargeVoice = -1; + charge = 0; + isCharging = false; +} + +// The weapon in the selected hotbar slot, or -1 if the slot is empty. +int Fighter::weapon_get() +{ + return weaponLoadout[slot]; +} + +// The slot whose cooldown, attacks and Doubler echoes a weapon slot's weapon uses: its own (each copy of a weapon is a +// weapon of its own), or -1 for an empty slot. +int Fighter::weapon_key(int weaponSlot) +{ + return weaponLoadout[weaponSlot] < 0 ? -1 : weaponSlot; +} + +// A weapon slot's level, its copy's own. +int Fighter::slot_level_get(int weaponSlot) +{ + return weaponSlotLevels[weaponSlot]; +} + +const LevelDef& Fighter::level_get(int weaponSlot) +{ + return WEAPON_DEFS[weaponLoadout[weaponSlot]].levels[slot_level_get(weaponSlot)]; +} + +// Puts an item in a slot (or empties it, with -1) at the item's level. +void Fighter::item_set(bool isTrinket, int itemSlot, int item) +{ + (isTrinket ? trinketLoadout : weaponLoadout)[itemSlot] = item; + if (item >= 0) + (isTrinket ? trinketSlotLevels : weaponSlotLevels)[itemSlot] = + (isTrinket ? trinketItemLevels : weaponItemLevels)[item]; +} + +// Every item and every copy at one level, as a match sets them each round. +void Fighter::levels_fill(int weaponLevel, int trinketLevel) +{ + std::fill(std::begin(weaponItemLevels), std::end(weaponItemLevels), weaponLevel); + std::fill(std::begin(weaponSlotLevels), std::end(weaponSlotLevels), weaponLevel); + std::fill(std::begin(trinketItemLevels), std::end(trinketItemLevels), trinketLevel); + std::fill(std::begin(trinketSlotLevels), std::end(trinketSlotLevels), trinketLevel); +} + +// What a trinket does, from its equipped copies, or the neutral value without any. Copies add what each does beyond +// neutral (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add), except that ones taking something away (below +// a neutral of 1, like Shrinker or Feather) multiply, so they never pass zero; the trinket's cap holds the total. +float Fighter::trinket_get(TrinketId trinket, float neutral) +{ + const TrinketDef& def = TRINKET_DEFS[trinket]; + float total = neutral; + bool isFirst = true; + for (int i = 0; i < trinketSlots; i++) + { + if (trinketLoadout[i] != trinket) + continue; + // The first copy counts exactly as it is (adding and taking back the neutral value would round it). + float value = def.values[trinketSlotLevels[i]]; + total = isFirst ? value : neutral != 0 && value < neutral ? total * value / neutral : total + value - neutral; + isFirst = false; + } + return def.cap > 0 ? std::min(total, def.cap) : total; +} + +// The best equipped copy's value, or the neutral value without any. +float Fighter::trinket_best_get(TrinketId trinket, float neutral) +{ + int level = trinket_level_get(trinket); + return level < 0 ? neutral : TRINKET_DEFS[trinket].values[level]; +} + +// A per-level number for a trinket (like Iron Weights' slam damage): the copies' added up, or 0 without any. +float Fighter::trinket_sum_get(int trinket, const float* table) +{ + float total = 0; + for (int i = 0; i < trinketSlots; i++) + total += trinketLoadout[i] == trinket ? table[trinketSlotLevels[i]] : 0; + return total; +} + +// The best equipped copy's level, or -1 without any. +int Fighter::trinket_level_get(int trinket) +{ + int level = -1; + for (int i = 0; i < trinketSlots; i++) + if (trinketLoadout[i] == trinket) + level = std::max(level, trinketSlotLevels[i]); + return level; +} + +int Fighter::trinket_count(int trinket) +{ + return std::count(trinketLoadout, trinketLoadout + trinketSlots, trinket); +} + +// What Shock Absorber leaves of knockback taken: part of the way to what it leaves of weapon movement. +float Fighter::knock_absorb_get() +{ + return 1 - (1 - trinket_get(TRINKET_ABSORBER, 1)) * ABSORB_KNOCK_SHARE; +} + +bool Fighter::is_trinket_equipped(int trinket) +{ + for (int item : trinketLoadout) + if (item == trinket) + return true; + return false; +} + +bool Fighter::is_down(Control control) +{ + return controls.buttons >> control & 1; +} + +// Whether any button went down since the last tick, for readying up while the fighter is frozen. It keeps the held +// buttons up to date, so buttons held over from the last round don't count until pressed again. +bool Fighter::is_any_pressed() +{ + bool isPressed = (controls.buttons & ~buttonsPrevious) != 0; + buttonsPrevious = controls.buttons; + return isPressed; +} + +bool Fighter::is_pressed(Control control) +{ + return is_down(control) && !(buttonsPrevious >> control & 1); +} + +bool Fighter::is_released(Control control) +{ + return !is_down(control) && buttonsPrevious >> control & 1; +} + +// Dual Wield's off-hand weapon slot: the last one switched away from if it still holds a weapon that isn't the one in +// hand, otherwise any other slot that does, or -1 if there is none. When weapons stack, a copy of the weapon in hand +// is another weapon; otherwise it's the same one. +int Fighter::off_slot_get() +{ + int held = weapon_key(slot); + int fallback = -1; + for (int i = 0; i < weaponSlots; i++) + { + if (weaponLoadout[i] < 0 || weapon_key(i) == held) + continue; + if (i == offSlot) + return i; + fallback = fallback < 0 ? i : fallback; + } + return fallback; +} + +int Fighter::off_weapon_get() +{ + int off = off_slot_get(); + return off < 0 ? -1 : weaponLoadout[off]; +} + +// Moves the box alpha of the way through the last tick. Only for drawing. +void Fighter::blend(float alpha) +{ + body.box.x = math_lerp(cornerPrevious.x, body.box.x, alpha); + body.box.y = math_lerp(cornerPrevious.y, body.box.y, alpha); +} + +// The animation for what the fighter is doing: tumbling when launched, kicking off or sliding down a wall, rolling, +// crawling, crouching, jumping or falling, walking, or standing. +const char* Fighter::animation_get() +{ + if (isLaunched) + return "Hurt"; + if (kickPose > 0) + return "Kick"; + if (slideWall != 0) + return "Slide"; + if (isSliding) + return "Roll"; + if (is_crawling()) + return CRAWL_ANIMATIONS[body.velocity.x < 0]; + if (isCrouching) + return "Crouch"; + if (!body.isGrounded) + return body.velocity.y < 0 ? "Jump" : "Fall"; + if (std::abs(body.velocity.x) > STEP_SPEED) + return WALK_ANIMATIONS[body.velocity.x < 0]; + return "Idle"; +} + +static bool is_pair(const char* const (&pair)[2], const char* animation) +{ + return animation == pair[0] || animation == pair[1]; +} + +// Plays the fighter's animation for this tick. Walking and crawling cycle one frame per stride of ground covered, the +// rest play in time. Turning round mid-stride carries on the cycle in the other direction's animation. +void Fighter::animator_update(Animator& animator) +{ + const char* animation = animation_get(); + bool isWalking = is_pair(WALK_ANIMATIONS, animation); + bool isCrawling = is_pair(CRAWL_ANIMATIONS, animation); + bool isTurning = isWalking && is_pair(WALK_ANIMATIONS, animator.animation) || + isCrawling && is_pair(CRAWL_ANIMATIONS, animator.animation); + float stride = isWalking ? WALK_STRIDE : isCrawling ? CRAWL_STRIDE : 0; + float frame = animator.frame; + animator.play(animation); + if (isTurning) + animator.frame = frame; + animator.update(stride > 0 ? std::abs(body.velocity.x) * TICK / stride : TICK * animator.art->fps); +} + +// The fighter's animation placed where it is right now, in its colour: feet on the ground facing its way (walking and +// crawling face the way they move), facing away from a wall it kicks off or slides down, spinning about its middle when +// launched, and rolling as a ball resting on the ground along the surface's perpendicular. Shared with afterimages. +Pose Fighter::pose_get(Game& game) +{ + Animator& animator = game.sprites.fighters[index]; + bool isDirected = is_pair(WALK_ANIMATIONS, animator.animation) || is_pair(CRAWL_ANIMATIONS, animator.animation); + float side = is_left() && !isDirected ? -1 : 1; + Draw draw = {.color = game.color_get(index), + .position = {center_get().x, body.box.y + body.box.h}, + .scale = {side * scale, scale}, + .flash = flash}; + if (isLaunched) + { + draw.position = center_get(); + draw.angle = tumble; + } + else if (kickPose > 0) + draw.scale.x = (body.velocity.x < 0 ? -1 : 1) * scale; + else if (slideWall != 0) + draw.scale.x = -slideWall * scale; + else if (isSliding) + { + Vec2 up = body.isGrounded ? vec2_normalize({body.slope, -1}) : Vec2{0, -1}; + draw.position = draw.position + up * (ROLL_RADIUS * scale); + draw.angle = roll; + } + return animator.pose_get(draw); +} + +Vec2 Fighter::center_get() +{ + return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2}; +} + +Vec2 Fighter::hand_get() +{ + return center_get() + Vec2{is_left() ? -HAND_OFFSET.x : HAND_OFFSET.x, HAND_OFFSET.y} * scale; +} + +// The far hand, just behind the body, where Dual Wield holds the off-hand weapon. +Vec2 Fighter::offhand_get() +{ + return center_get() + Vec2{is_left() ? -OFFHAND_OFFSET.x : OFFHAND_OFFSET.x, OFFHAND_OFFSET.y} * scale; +} + +// Full charge pulses white on and off, shared by the charge bar and the held weapon. +bool Fighter::is_crawling() +{ + return isCrouching && !isSliding && std::abs(body.velocity.x) > STEP_SPEED / 2; +} + +bool Fighter::is_flashing() +{ + return charge >= 1 && SDL_GetTicks() / BLINK_MILLISECONDS % 2; +} + +bool Fighter::is_left() +{ + return isFacingLeft; +} diff --git a/src/fighter.h b/src/fighter.h new file mode 100644 index 0000000..cb1f6e4 --- /dev/null +++ b/src/fighter.h @@ -0,0 +1,173 @@ +#pragma once + +#include "anm2.h" +#include "input.h" +#include "renderer.h" +#include "trinket.h" +#include "world.h" + +struct Attack; + +constexpr int ECHO_MAX = 64; +constexpr int MAX_FIGHTERS = SPAWN_COUNT; +constexpr float BOUNCE_KEEP = 0.75f; +constexpr float SECONDARY_SHADE = 0.5f; +constexpr int COLOR_COUNT = 16; +constexpr int COLOR_UNASSIGNED = -1; +// The colours fighters can wear, in the order fighters without one are given them. +constexpr Color PALETTE[COLOR_COUNT] = { + {1, 0.35f, 0.35f, 1}, {0.4f, 0.6f, 1, 1}, {1, 0.9f, 0.3f, 1}, {0.4f, 1, 0.45f, 1}, + {1, 0.6f, 0.2f, 1}, {0.7f, 0.45f, 1, 1}, {0.3f, 0.95f, 1, 1}, {1, 0.5f, 0.8f, 1}, + {0.75f, 1, 0.3f, 1}, {0.2f, 0.75f, 0.7f, 1}, {1, 0.3f, 1, 1}, {0.6f, 0.85f, 1, 1}, + {1, 0.78f, 0.6f, 1}, {0.8f, 0.75f, 1, 1}, {0.75f, 0.5f, 0.3f, 1}, {0.75f, 0.78f, 0.85f, 1}, +}; +constexpr const char* COLOR_NAMES[COLOR_COUNT] = {"Red", "Blue", "Yellow", "Green", "Orange", "Purple", + "Cyan", "Pink", "Lime", "Teal", "Magenta", "Sky", + "Peach", "Lavender", "Brown", "Silver"}; +constexpr float BOUNCE_MIN = 120; +constexpr float BOX_HEIGHT = 24; +constexpr float BOX_WIDTH = 12; +constexpr float JUMP_SPEED = 400; +constexpr float KNOCK_DECAY = 1.2f; +constexpr float LAUNCH_END_SPEED = 200; +constexpr float RUN_SPEED = 150; +constexpr float RACE_KNOCK = 0.5f; +constexpr float SLAM_SPEED = 400; +constexpr float CHARGE_TIME = 1.0f; +constexpr float CHARGE_VISIBLE = 0.15f; +// Shock Absorber takes off this share of what it takes off weapon movement from knockback. +constexpr float ABSORB_KNOCK_SHARE = 2 / 3.0f; + +// A platforming fighter driven by Controls: movement, aiming, the shared weapon charge, and everything it carries. +// Loadouts hold weapon and trinket numbers (-1 for empty), with only their first slots counts in use (the rest stay +// empty). The same item can sit in several slots, each copy with its own level (slot levels); item levels are what a +// copy gets when equipped, and what an unequipped item is described at. Copies always stack: weapon copies are separate +// weapons with their own cooldowns (Dual Wield can hold two of one), and trinket copies add up. Cooldowns (per weapon +// key, see weapon_key), Doubler echoes and the kick animation belong to its weapons. Index is its place in the game's +// fighter list, which its attacks record. Hitstop freezes it (and its attacks) after landing or taking a heavy hit. A +// big hit launches it: it flies, bouncing off whatever it hits, tumbling, with no say in where it goes until it slows +// down. Crouching on the ground lowers it, slows it to a waddle and halves the knockback it takes. Spinning a flail +// lightens its gravity. Hard bounces hurt, credited to the launcher, whoever landed the last hit. In a race, hits only +// knock it about (half as hard), and touching a pit puts it back on the last ground it stood squarely on. Slopes slow +// it climbing and speed it up descending. Crouching on one slides it down, rolling, and the slide carries on over flat +// ground until it slows. +class Fighter +{ +public: + Body body; + Controls controls; + Vec2 aim = {1, 0}; + FixedList echoes; + float charge = 0; + float cooldowns[SLOT_MAX] = {}; + float flash = 0; + float health = 0; + float hitstop = 0; + float kickFrom = 0; + float kickTime = 1; + float kickTo = 0; + float scale = 1; + float shockCooldown = 0; + float contactCooldown = 0; + int index = 0; + int launcher = 0; + int offSlot = -1; + int slot = 0; + int trinketItemLevels[TRINKET_COUNT] = {}; + int trinketSlotLevels[SLOT_MAX] = {}; + int trinketLoadout[SLOT_MAX]; + int trinketSlots = SLOTS_DEFAULT; + int weaponItemLevels[WEAPON_COUNT] = {}; + int weaponSlotLevels[SLOT_MAX] = {}; + int weaponLoadout[SLOT_MAX]; + int weaponSlots = SLOTS_DEFAULT; + bool isAlive = true; + bool isBot = false; + bool isCrouching = false; + bool isSpinning = false; + bool isLaunched = false; + // In the sandbox, whether it fights other fighters (both need it on); without it, it's drawn darkened. + bool isPvp = false; + bool isSliding = false; + + void init(Game& game, int number); + void state_reset(Game& game, float maxHealth); + void position_reset(Game& game); + void position_set(Game& game, Vec2 feet); + void slots_set(int weapons, int trinkets); + void damage_take(Game& game, Damage damage); + void update(Game& game); + void draw(Game& game); + void blend(float alpha); + void impulse_add(Vec2 impulse, bool isDamped = true); + void teleport_to(Vec2 centre); + void burst_add(Vec2 impulse); + void charge_cancel(Game& game); + int weapon_get(); + int weapon_key(int weaponSlot); + int off_slot_get(); + int off_weapon_get(); + int slot_level_get(int weaponSlot); + const LevelDef& level_get(int weaponSlot); + void item_set(bool isTrinket, int itemSlot, int item); + void levels_fill(int weaponLevel, int trinketLevel); + float trinket_get(TrinketId trinket, float neutral); + float trinket_best_get(TrinketId trinket, float neutral); + float trinket_sum_get(int trinket, const float* table); + int trinket_level_get(int trinket); + int trinket_count(int trinket); + float trinket_cooldown_get(int trinket); + float knock_absorb_get(); + float pound_area_get(); + const char* animation_get(); + void animator_update(Animator& animator); + Pose pose_get(Game& game); + Vec2 center_get(); + Vec2 hand_get(); + Vec2 offhand_get(); + bool is_any_pressed(); + bool is_down(Control control); + bool is_pressed(Control control); + bool is_released(Control control); + bool is_trinket_equipped(int trinket); + int wall_get(Game& game); + bool is_crawling(); + bool is_flashing(); + bool is_left(); + +private: + Vec2 cornerPrevious; + Vec2 safe; + float coyoteTimer = 0; + float dashCooldown = 0; + float dashTimer = 0; + float jumpBuffer = 0; + float kickTimer = 0; + float kickPose = 0; + float slideTimer = 0; + float stepDistance = 0; + float wallMemory = 0; + float roll = 0; + float rollSpin = 0; + float tumble = 0; + float wallSpeed = 0; + int chargeVoice = -1; + int slideWall = 0; + Uint16 buttonsPrevious = 0; + bool isCharging = false; + bool isFireBlocked = false; + bool isFacingLeft = false; + bool isJumping = false; + + void slot_update(Game& game); + void safe_update(Game& game); + void size_update(); + void dash_update(Game& game, bool isJumpPressed); + void climb_update(Game& game); + Vec2 wall_point_get(int wall); + void wall_push(Game& game, int wall, Vec2 base); + void bounce_resolve(Game& game, Vec2 before); + void run_update(Attack* hook); + void rope_update(Attack& hook); + void charge_update(Game& game); +}; diff --git a/src/game.h b/src/game.h new file mode 100644 index 0000000..6038956 --- /dev/null +++ b/src/game.h @@ -0,0 +1,103 @@ +#pragma once + +#include "audio.h" +#include "balance.h" +#include "bot.h" +#include "chat.h" +#include "menu.h" +#include "effects.h" +#include "fighter.h" +#include "input.h" +#include "match.h" +#include "net.h" +#include "renderer.h" +#include "rollback.h" +#include "session.h" +#include "settings.h" +#include "sprites.h" +#include "text.h" +#include "trinket.h" +#include "ui.h" +#include "weapon.h" +#include "world.h" + +constexpr float PVP_OFF_SHADE = 0.4f; +constexpr float AWAY_SHADE = 0.2f; + +// Every manager in one place, so systems can reach each other through a single reference. +// Devices say which input plays each fighter on this screen (none for bots). Fighters, the match and rng belong to the +// simulation and are saved in snapshots. Local is the fighter this screen +// controls and follows. Camera, alpha, settings and the window are presentation. Alpha is how far drawing sits between +// the previous tick and the current one. Net is the connection to the server and other players. +struct Game +{ + Audio audio; + Balance balance; + Bot bot; + Chat chat; + Menu menu; + Effects effects; + Fighter fighters[MAX_FIGHTERS]; + Input input; + Match match; + Net net; + Renderer renderer; + Rollback rollback; + Settings settings; + Session session; + Sprites sprites; + Text text; + Ui ui; + Weapons weapons; + World world; + Rng rng; + Vec2 camera; + Vec2 cameraPrevious; + float alpha = 1; + int devices[MAX_FIGHTERS] = {DEVICE_KEYBOARD, DEVICE_NONE, DEVICE_NONE, DEVICE_NONE}; + int fighterCount = 1; + int local = 0; + SDL_Window* window = nullptr; + + Fighter& local_get() + { + return fighters[local]; + } + + // Whether a fighter is played on this screen, by its keyboard and mouse or one of its gamepads. + bool is_local(int fighter) + { + return fighter < fighterCount && devices[fighter] != DEVICE_NONE && !fighters[fighter].isBot; + } + + int local_count() + { + int count = 0; + for (int i = 0; i < fighterCount; i++) + count += is_local(i); + return count; + } + + // A fighter's colour: its match colour (handed out when the match starts), or in the sandbox this screen's own + // (picked in Customize, white while unassigned) until others join, when everyone + // gets one. With others in the sandbox, everything of another fighter is darkened on this screen unless it and this + // screen's own both have PvP on (so they can hurt each other); this screen's own always shows in full. Asked for + // plain (as the HUD's tint is), it's never darkened. + Color color_get(int fighter, bool isPlain = false) + { + if (match.state == MATCH_PRACTICE && fighterCount == 1) + { + int color = settings.color_get(); + return color >= 0 ? PALETTE[color] : WHITE; + } + Color color = PALETTE[match.colors[fighter]]; + // Away playing a queued game, a fighter stands faded in the lobby. + if (match.state == MATCH_PRACTICE && match.isAway[fighter] && !isPlain) + return color_shade(color, AWAY_SHADE); + bool isHostile = fighters[fighter].isPvp && fighters[local].isPvp; + bool isPeaceful = !isPlain && fighter != local && match.state == MATCH_PRACTICE && !isHostile; + return isPeaceful ? color_shade(color, PVP_OFF_SHADE) : color; + } +}; + +void game_tick(Game& game); diff --git a/src/input.cpp b/src/input.cpp new file mode 100644 index 0000000..8052efa --- /dev/null +++ b/src/input.cpp @@ -0,0 +1,530 @@ +#include "input.h" +#include "game.h" +#include +#include +#include + +constexpr int SLOT_KEY_COUNT = 9; +constexpr int BIND_MOUSE = KEY_COUNT; +constexpr int BIND_WHEEL_UP = BIND_MOUSE + MOUSE_COUNT; +constexpr int BIND_WHEEL_DOWN = BIND_WHEEL_UP + 1; +constexpr int BIND_AXIS = 100; +constexpr float AXIS_MAX = 32767; +constexpr float AXIS_THRESHOLD = 0.5f; +constexpr float AIM_DEADZONE = 0.3f; +constexpr float AIM_REACH = 120; +constexpr float NAV_THRESHOLD = 0.6f; +constexpr Uint64 NAV_DELAY = 400; +constexpr Uint64 NAV_INTERVAL = 90; +constexpr SDL_Scancode NAV_KEYS[] = {SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT}; +constexpr SDL_GamepadButton NAV_BUTTONS[] = {SDL_GAMEPAD_BUTTON_DPAD_UP, SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, SDL_GAMEPAD_BUTTON_DPAD_RIGHT}; + +// A gamepad binding for a stick or trigger leaning one way. +static constexpr int axis_binding(SDL_GamepadAxis axis, bool isPositive) +{ + return BIND_AXIS + axis * 2 + isPositive; +} + +constexpr Binding DEFAULT_BINDINGS[CONTROL_COUNT] = { + {SDL_SCANCODE_A, axis_binding(SDL_GAMEPAD_AXIS_LEFTX, false)}, + {SDL_SCANCODE_D, axis_binding(SDL_GAMEPAD_AXIS_LEFTX, true)}, + {SDL_SCANCODE_W, axis_binding(SDL_GAMEPAD_AXIS_LEFTY, false)}, + {SDL_SCANCODE_S, axis_binding(SDL_GAMEPAD_AXIS_LEFTY, true)}, + {SDL_SCANCODE_SPACE, SDL_GAMEPAD_BUTTON_SOUTH}, + {BIND_MOUSE + MOUSE_LEFT, axis_binding(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, true)}, + {BIND_MOUSE + MOUSE_RIGHT, axis_binding(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, true)}, + {SDL_SCANCODE_Q, SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + {SDL_SCANCODE_E, SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {SDL_SCANCODE_R, SDL_GAMEPAD_BUTTON_NORTH}, + {BIND_WHEEL_DOWN, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {BIND_WHEEL_UP, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {SDL_SCANCODE_TAB, SDL_GAMEPAD_BUTTON_START}, + {SDL_SCANCODE_T, -1}, +}; +constexpr const char* MOUSE_NAMES[MOUSE_COUNT] = {"Mouse Left", "Mouse Right", "Mouse Middle"}; +constexpr const char* PAD_BUTTON_NAMES[] = {"A", "B", "X", "Y", "Back", + "Guide", "Start", "L Stick", "R Stick", "L Bumper", + "R Bumper", "D-Pad Up", "D-Pad Down", "D-Pad Left", "D-Pad Right"}; +constexpr const char* PAD_AXIS_NAMES[] = {"L Stick Left", "L Stick Right", "L Stick Up", "L Stick Down", + "R Stick Left", "R Stick Right", "R Stick Up", "R Stick Down", + "L Trigger", "L Trigger", "R Trigger", "R Trigger"}; + +Input::Input() +{ + bindings_reset(); +} + +// Reads the test gamepad script named by RPG_TEST_PADS and attaches a virtual gamepad for each pad it uses. +void Input::script_load() +{ + const char* path = SDL_getenv("RPG_TEST_PADS"); + char* text = path ? (char*)SDL_LoadFile(path, nullptr) : nullptr; + if (!text) + return; + char* rest = nullptr; + int pads = 0; + for (char* line = SDL_strtok_r(text, "\r\n", &rest); line; line = SDL_strtok_r(nullptr, "\r\n", &rest)) + { + char kind[TYPED_LENGTH] = {}; + PadEvent event; + if (SDL_sscanf(line, "%f %d %63s %d %f", &event.time, &event.pad, kind, &event.index, &event.value) != 5) + continue; + event.isAxis = SDL_strcmp(kind, "axis") == 0; + script.push_back(event); + pads = std::max(pads, event.pad + 1); + } + SDL_free(text); + + for (int i = 0; i < pads; i++) + { + SDL_VirtualJoystickDesc desc; + SDL_INIT_INTERFACE(&desc); + desc.type = SDL_JOYSTICK_TYPE_GAMEPAD; + desc.naxes = SDL_GAMEPAD_AXIS_COUNT; + desc.nbuttons = SDL_GAMEPAD_BUTTON_COUNT; + desc.axis_mask = (1u << SDL_GAMEPAD_AXIS_COUNT) - 1; + desc.button_mask = (1u << SDL_GAMEPAD_BUTTON_COUNT) - 1; + desc.name = "Test Pad"; + virtualPads.push_back(SDL_OpenJoystick(SDL_AttachVirtualJoystick(&desc))); + } +} + +// Applies every scripted press that's come due. +void Input::script_play(Game& game) +{ + float now = SDL_GetTicks() / (float)SDL_MS_PER_SECOND; + while (!script.empty() && script.front().time <= now) + { + PadEvent event = script.front(); + script.erase(script.begin()); + SDL_Joystick* pad = event.pad < (int)virtualPads.size() ? virtualPads[event.pad] : nullptr; + if (pad && event.isAxis) + SDL_SetJoystickVirtualAxis(pad, event.index, event.value * AXIS_MAX); + else if (pad) + SDL_SetJoystickVirtualButton(pad, event.index, event.value > 0); + } +} + +void Input::bindings_reset() +{ + std::copy(std::begin(DEFAULT_BINDINGS), std::end(DEFAULT_BINDINGS), bindings); +} + +void Input::poll(Game& game) +{ + bool isTyping = SDL_TextInputActive(game.window); + bool isCapturing = captureControl >= 0; + typed[0] = '\0'; + erases = 0; + scroll = 0; + isConfirmed = false; + isMouseMoved = false; + isSubmitted = false; + isCancelled = false; + for (Device& device : devices) + { + device.presses = 0; + device.nav = 0; + } + if (virtualPads.empty() && SDL_getenv("RPG_TEST_PADS")) + script_load(); + script_play(game); + + SDL_Event event; + while (SDL_PollEvent(&event)) + { + SDL_Scancode scancode = event.key.scancode; + switch (event.type) + { + case SDL_EVENT_QUIT: + isQuit = true; + break; + case SDL_EVENT_GAMEPAD_ADDED: + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + if (!devices[i].pad) + { + devices[i].pad = SDL_OpenGamepad(event.gdevice.which); + break; + } + break; + case SDL_EVENT_GAMEPAD_REMOVED: + for (Device& device : devices) + if (device.pad && SDL_GetGamepadID(device.pad) == event.gdevice.which) + { + SDL_CloseGamepad(device.pad); + device = {}; + } + break; + case SDL_EVENT_MOUSE_MOTION: + devices[DEVICE_KEYBOARD].isAiming = false; + isMouseMoved = true; + break; + case SDL_EVENT_MOUSE_WHEEL: + if (isCapturing && !isCaptureGamepad) + capture_take(event.wheel.y > 0 ? BIND_WHEEL_UP : BIND_WHEEL_DOWN); + else + { + wheel += event.wheel.y; + scroll += event.wheel.y; + } + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + { + int button = event.button.button == SDL_BUTTON_LEFT ? MOUSE_LEFT + : event.button.button == SDL_BUTTON_RIGHT ? MOUSE_RIGHT + : MOUSE_MIDDLE; + if (isCapturing && !isCaptureGamepad) + capture_take(BIND_MOUSE + button); + else + presses[button] = true; + break; + } + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + if (isCapturing && isCaptureGamepad) + capture_take(event.gbutton.button); + else if (isCapturing) + { + if (event.gbutton.button == SDL_GAMEPAD_BUTTON_EAST) + capture_take(-2); + } + else + for (Device& device : devices) + if (device.pad && SDL_GetGamepadID(device.pad) == event.gbutton.which) + { + device.presses |= 1u << event.gbutton.button; + device.taps |= 1u << event.gbutton.button; + } + break; + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + if (isCapturing && isCaptureGamepad && std::abs(event.gaxis.value / AXIS_MAX) > AXIS_THRESHOLD) + capture_take(axis_binding((SDL_GamepadAxis)event.gaxis.axis, event.gaxis.value > 0)); + break; + case SDL_EVENT_TEXT_INPUT: + SDL_strlcat(typed, event.text.text, sizeof(typed)); + break; + case SDL_EVENT_KEY_DOWN: + if (isCapturing && !event.key.repeat) + { + if (scancode == SDL_SCANCODE_ESCAPE) + capture_take(-2); + else if (!isCaptureGamepad) + capture_take(scancode); + break; + } + keysTapped[scancode] |= !isTyping && scancode < KEY_COUNT; + for (int i = 0; i < (int)std::size(NAV_KEYS) && !isTyping; i++) + devices[DEVICE_KEYBOARD].nav |= (scancode == NAV_KEYS[i]) << i; + isConfirmed |= !isTyping && !event.key.repeat && scancode == SDL_SCANCODE_RETURN; + erases += isTyping && scancode == SDL_SCANCODE_BACKSPACE; + isSubmitted |= isTyping && (scancode == SDL_SCANCODE_RETURN || scancode == SDL_SCANCODE_ESCAPE); + isCancelled |= isTyping && scancode == SDL_SCANCODE_ESCAPE; + if (!isTyping && scancode >= SDL_SCANCODE_1 && scancode < SDL_SCANCODE_1 + SLOT_KEY_COUNT) + slotPressed = scancode - SDL_SCANCODE_1; + if (!isTyping && scancode == SDL_SCANCODE_F && !event.key.repeat) + game.settings.fullscreen_toggle(game); + break; + default: + break; + } + } + + // Keys still held when typing ends (like the Enter that ended it), or that were just bound, don't count as fresh + // presses. + int count = 0; + const bool* state = SDL_GetKeyboardState(&count); + std::memcpy(keys, state, std::min(count, KEY_COUNT) * sizeof(bool)); + Vec2 window; + SDL_MouseButtonFlags flags = SDL_GetMouseState(&window.x, &window.y); + buttons[MOUSE_LEFT] = flags & SDL_BUTTON_LMASK; + buttons[MOUSE_RIGHT] = flags & SDL_BUTTON_RMASK; + buttons[MOUSE_MIDDLE] = flags & SDL_BUTTON_MMASK; + if (isTyping) + std::fill(std::begin(keys), std::end(keys), false); + else if (wasTyping || isSwallowed || isCapturing) + { + std::memcpy(keysPrevious, keys, sizeof(keys)); + std::memcpy(buttonsPrevious, buttons, sizeof(buttons)); + } + wasTyping = isTyping; + isClicked = presses[MOUSE_LEFT] && !isSwallowed && !isCapturing; + isRightClicked = presses[MOUSE_RIGHT] && !isSwallowed && !isCapturing; + for (int i = 0; i < MOUSE_COUNT; i++) + pressesUnticked[i] |= presses[i]; + std::fill(std::begin(presses), std::end(presses), false); + bool isNavBlocked = isSwallowed || isCapturing; + isSwallowed = false; + + // Each gamepad's state as of now. + for (Device& device : devices) + { + if (!device.pad) + continue; + device.down = 0; + for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) + device.down |= (Uint32)SDL_GetGamepadButton(device.pad, (SDL_GamepadButton)button) << button; + for (int axis = 0; axis < PAD_AXES; axis++) + device.axes[axis] = SDL_GetGamepadAxis(device.pad, (SDL_GamepadAxis)axis) / AXIS_MAX; + } + + // Menu directions: the arrow keys for the keyboard's device (repeating as keys do), and each gamepad's D-pad and + // left stick (leaning past a threshold counts), repeating while held. None while a binding is being taken. + Uint64 now = SDL_GetTicks(); + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + { + Device& device = devices[i]; + if (!device.pad) + continue; + Vec2 axes = {device.axes[SDL_GAMEPAD_AXIS_LEFTX], device.axes[SDL_GAMEPAD_AXIS_LEFTY]}; + int held = (axes.y < -NAV_THRESHOLD) * NAV_UP | (axes.y > NAV_THRESHOLD) * NAV_DOWN | + (axes.x < -NAV_THRESHOLD) * NAV_LEFT | (axes.x > NAV_THRESHOLD) * NAV_RIGHT; + int fresh = 0; + for (int button = 0; button < (int)std::size(NAV_BUTTONS); button++) + { + held |= (device.down >> NAV_BUTTONS[button] & 1) << button; + fresh |= (device.presses >> NAV_BUTTONS[button] & 1) << button; + } + fresh |= held & ~device.held; + if (fresh) + device.repeat = now + NAV_DELAY; + else if (held && now >= device.repeat) + { + fresh = held; + device.repeat = now + NAV_INTERVAL; + } + device.held = held; + device.nav |= isNavBlocked ? 0 : fresh; + } + if (isNavBlocked) + devices[DEVICE_KEYBOARD].nav = 0; + + // Each gamepad's right stick aims while it leans. An unclaimed one aims for the keyboard's device. + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + { + Device& device = devices[i]; + if (!device.pad) + continue; + Vec2 axes = {device.axes[SDL_GAMEPAD_AXIS_RIGHTX], device.axes[SDL_GAMEPAD_AXIS_RIGHTY]}; + if (vec2_length(axes) <= AIM_DEADZONE) + continue; + Device& aimer = device.isClaimed ? device : devices[DEVICE_KEYBOARD]; + aimer.stick = vec2_normalize(axes); + aimer.isAiming = true; + } + + int width = 0; + int height = 0; + SDL_GetWindowSizeInPixels(game.window, &width, &height); + float density = SDL_GetWindowPixelDensity(game.window); + Rect box = game.renderer.letterbox_get(width, height); + mouse = {(window.x * density - box.x) / box.w * CANVAS_WIDTH, (window.y * density - box.y) / box.h * CANVAS_HEIGHT}; +} + +// Ends a capture with what was pressed (-2 cancels), binding it to the captured side of the control. The press is +// swallowed so it doesn't also act. +void Input::capture_take(int binding) +{ + if (binding != -2) + { + int& side = isCaptureGamepad ? bindings[captureControl].pad : bindings[captureControl].key; + side = binding; + isBindingChanged = true; + } + captureControl = -1; + isSwallowed = true; +} + +// Where a device's fighter aims: along its right stick from the fighter's middle (the keyboard's device only while a +// stick is in use), otherwise at the mouse. +Vec2 Input::aim_get(int device, Vec2 camera, Vec2 origin) +{ + bool isStick = device != DEVICE_KEYBOARD || devices[device].isAiming; + return isStick ? origin + devices[device].stick * AIM_REACH : mouse + camera; +} + +// Queues an edit to go out with one of the device's next ticks of controls (dropped if the queue is somehow full). +void Input::edit_send(int device, int edit) +{ + Device& source = devices[device]; + if (source.editCount < EDIT_QUEUE) + source.edits[source.editCount++] = edit; +} + +// A device's bound keys, buttons and sticks as one tick of fighter controls, aiming from origin, with the oldest edit +// and any Ready click waiting. Number keys (on the keyboard), next and previous weapon step from the fighter's current +// slot, and the fighter wraps it into range. With the menu open, only Ready and picks get through. +Controls Input::controls_get(int device, Vec2 camera, Vec2 origin, int slot, bool isMenuOpen) +{ + Device& source = devices[device]; + bool isKeyboard = device == DEVICE_KEYBOARD; + Controls controls = {.cursor = aim_get(device, camera, origin), + .edit = source.editCount > 0 ? source.edits[0] : -1, + .slot = isMenuOpen || !isKeyboard ? -1 : slotPressed}; + for (int i = 0; i < CONTROL_COUNT; i++) + { + bool isHeld = is_control_down(device, (Control)i) && (!isMenuOpen || i == CONTROL_READY); + controls.buttons |= isHeld << i; + } + controls.buttons |= source.isReady << CONTROL_READY; + if (source.editCount > 0) + std::copy(source.edits + 1, source.edits + source.editCount--, source.edits); + source.isReady = false; + if (isMenuOpen) + return controls; + if (is_control_down(device, CONTROL_NEXT_WEAPON) && !source.previous[CONTROL_NEXT_WEAPON]) + controls.slot = slot + 1; + if (is_control_down(device, CONTROL_PREVIOUS_WEAPON) && !source.previous[CONTROL_PREVIOUS_WEAPON]) + controls.slot = slot - 1; + return controls; +} + +void Input::tick_end() +{ + for (int device = 0; device < DEVICE_COUNT; device++) + { + for (int i = 0; i < CONTROL_COUNT; i++) + devices[device].previous[i] = is_control_down(device, (Control)i); + devices[device].taps = 0; + } + std::memcpy(keysPrevious, keys, sizeof(keys)); + std::memcpy(buttonsPrevious, buttons, sizeof(buttons)); + std::fill(std::begin(pressesUnticked), std::end(pressesUnticked), false); + std::fill(std::begin(keysTapped), std::end(keysTapped), false); + wheel = 0; + slotPressed = -1; + isPlayRequested = false; +} + +// Whether a device holds a control: a claimed gamepad by its own binding, the keyboard's device by its key binding +// or any unclaimed gamepad's. +bool Input::is_control_down(int device, Control control) +{ + Binding binding = bindings[control]; + if (device != DEVICE_KEYBOARD) + return is_pad_binding_down(devices[device], binding.pad); + bool isDown = is_key_binding_down(binding.key); + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + isDown |= !devices[i].isClaimed && is_pad_binding_down(devices[i], binding.pad); + return isDown; +} + +// The keyboard's device pressed a control this tick (menus use it). +bool Input::is_control_pressed(Control control) +{ + return is_control_down(DEVICE_KEYBOARD, control) && !devices[DEVICE_KEYBOARD].previous[control]; +} + +// Whether a control's keyboard-side binding (a key, mouse button or wheel turn) was pressed this tick. Menus use it +// where the gamepad side already means something else. +bool Input::is_key_control_pressed(Control control) +{ + int binding = bindings[control].key; + if (binding < 0) + return false; + if (binding < KEY_COUNT) + return is_pressed(binding) || (keysTapped[binding] && !keysPrevious[binding]); + if (binding < BIND_WHEEL_UP) + return is_mouse_pressed((MouseButton)(binding - BIND_MOUSE)); + return binding == BIND_WHEEL_UP ? wheel > 0 : wheel < 0; +} + +// The menu directions a device pressed since the last poll (the keyboard's device counts unclaimed gamepads). +int Input::nav_get(int device) +{ + int nav = devices[device].nav; + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT && device == DEVICE_KEYBOARD; i++) + nav |= devices[i].isClaimed ? 0 : devices[i].nav; + return nav; +} + +// Whether a device pressed a gamepad button since the last poll (the keyboard's device counts unclaimed gamepads). +bool Input::is_pad_pressed(int device, int button) +{ + return pad_presses_get(device) >> button & 1; +} + +// Whether the keyboard's device (any unclaimed gamepad) pressed a gamepad button since the last tick. +bool Input::is_pad_tapped(int button) +{ + bool isTapped = false; + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + isTapped |= !devices[i].isClaimed && (devices[i].taps >> button & 1); + return isTapped; +} + +// Every gamepad button a device pressed since the last poll, a bit each. +Uint32 Input::pad_presses_get(int device) +{ + Uint32 presses = devices[device].presses; + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT && device == DEVICE_KEYBOARD; i++) + presses |= devices[i].isClaimed ? 0 : devices[i].presses; + return presses; +} + +bool Input::is_key_binding_down(int binding) +{ + if (binding < 0) + return false; + if (binding < KEY_COUNT) + return keys[binding] || keysTapped[binding]; + if (binding < BIND_WHEEL_UP) + return buttons[binding - BIND_MOUSE] || pressesUnticked[binding - BIND_MOUSE]; + return binding == BIND_WHEEL_UP ? wheel > 0 : wheel < 0; +} + +bool Input::is_pad_binding_down(Device& device, int binding) +{ + if (!device.pad || binding < 0) + return false; + if (binding < BIND_AXIS) + return ((device.down | device.taps) >> binding & 1) != 0; + int axis = (binding - BIND_AXIS) / 2; + float value = axis < PAD_AXES ? device.axes[axis] : 0; + return (binding - BIND_AXIS) % 2 ? value > AXIS_THRESHOLD : value < -AXIS_THRESHOLD; +} + +// A binding as shown in the Controls settings: a key's name, a mouse button or wheel direction, or a gamepad button, +// stick or trigger direction. Unbound shows a dash. +void Input::binding_name_get(int binding, bool isGamepad, char* text, int size) +{ + if (binding < 0) + SDL_strlcpy(text, "-", size); + else if (isGamepad && binding >= BIND_AXIS) + SDL_strlcpy(text, PAD_AXIS_NAMES[(binding - BIND_AXIS) % std::size(PAD_AXIS_NAMES)], size); + else if (isGamepad && binding < (int)std::size(PAD_BUTTON_NAMES)) + SDL_strlcpy(text, PAD_BUTTON_NAMES[binding], size); + else if (isGamepad) + SDL_snprintf(text, size, "Button %d", binding); + else if (binding < KEY_COUNT) + SDL_strlcpy(text, SDL_GetScancodeName((SDL_Scancode)binding), size); + else if (binding < BIND_WHEEL_UP) + SDL_strlcpy(text, MOUSE_NAMES[binding - BIND_MOUSE], size); + else + SDL_strlcpy(text, binding == BIND_WHEEL_UP ? "Wheel Up" : "Wheel Down", size); +} + +bool Input::is_down(int scancode) +{ + return keys[scancode]; +} + +bool Input::is_pressed(int scancode) +{ + return keys[scancode] && !keysPrevious[scancode]; +} + +bool Input::is_mouse_down(MouseButton button) +{ + return buttons[button]; +} + +// Pressed since the last tick, counting presses let go of before it could see them held. +bool Input::is_mouse_pressed(MouseButton button) +{ + return (buttons[button] && !buttonsPrevious[button]) || pressesUnticked[button]; +} + +bool Input::is_mouse_released(MouseButton button) +{ + return !buttons[button] && buttonsPrevious[button]; +} diff --git a/src/input.h b/src/input.h new file mode 100644 index 0000000..c3023de --- /dev/null +++ b/src/input.h @@ -0,0 +1,198 @@ +#pragma once + +#include "common.h" +#include + +struct Game; +struct SDL_Gamepad; +struct SDL_Joystick; + +constexpr int KEY_COUNT = 512; +constexpr int TYPED_LENGTH = 64; + +enum Control +{ + CONTROL_LEFT, + CONTROL_RIGHT, + CONTROL_UP, + CONTROL_DOWN, + CONTROL_JUMP, + CONTROL_ATTACK, + CONTROL_CHARGE, + CONTROL_LEVEL_DOWN, + CONTROL_LEVEL_UP, + CONTROL_READY, + CONTROL_NEXT_WEAPON, + CONTROL_PREVIOUS_WEAPON, + CONTROL_MENU, + CONTROL_CHAT, + CONTROL_COUNT +}; + +// What each control is called in the Controls settings, and its key in settings.txt. +constexpr const char* CONTROL_NAMES[CONTROL_COUNT] = { + "Move Left", "Move Right", "Up", "Down", "Jump", "Attack", "Charge", "Level Down", "Level Up", "Ready", + "Next Weapon", "Previous Weapon", "Menu", "Chat"}; +constexpr const char* CONTROL_KEYS[CONTROL_COUNT] = { + "left", "right", "up", "down", "jump", "attack", "charge", "level_down", "level_up", "ready", + "next_weapon", "previous_weapon", "menu", "chat"}; + +// One tick of a fighter's intent, from the keyboard, a gamepad or a bot. Buttons holds a bit per Control held this +// tick, cursor is the world point it aims at, slot is a weapon slot to switch to (-1 for none), and edit a change to +// its loadout or settings made from the menu this tick (see edit_encode, -1 for none). +struct Controls +{ + Vec2 cursor; + Uint16 buttons = 0; + int edit = -1; + int slot = -1; +}; + +constexpr int EDIT_QUEUE = 16; + +enum MouseButton +{ + MOUSE_LEFT, + MOUSE_RIGHT, + MOUSE_MIDDLE, + MOUSE_COUNT +}; + +// A control's two bindings: one on the keyboard side (a key's scancode, or past the keys a mouse button or wheel +// direction) and one on the gamepad (a button, or past the buttons a stick or trigger direction). -1 is unbound. +struct Binding +{ + int key = -1; + int pad = -1; +}; + +constexpr int PAD_MAX = 4; +constexpr int DEVICE_NONE = -1; +constexpr int DEVICE_KEYBOARD = 0; +constexpr int DEVICE_COUNT = PAD_MAX + 1; +constexpr int PAD_AXES = 6; + +// Menu navigation directions, as bits. +enum Nav +{ + NAV_UP = 1, + NAV_DOWN = 2, + NAV_LEFT = 4, + NAV_RIGHT = 8 +}; + +// One step of the test gamepad script: at time seconds, pad's button (or axis) index goes to value. +struct PadEvent +{ + float time = 0; + float value = 0; + int index = 0; + int pad = 0; + bool isAxis = false; +}; + +// One way of playing: the keyboard and mouse, or a gamepad. Previous is what its controls were last tick (for +// presses), stick the last way its right stick leaned, pick a Choice pick and ready a Ready click waiting for the next +// tick, presses the gamepad buttons pressed since the last poll (for menus) and taps those pressed since the last +// tick (held for it, however quick). Nav is the menu directions pressed since the last poll (D-pad, left stick or +// arrow keys, repeating while held), held the D-pad and left stick directions held, and repeat when they next repeat. +// Down and axes are the gamepad's buttons held and sticks and triggers (-1 to 1) as of the last poll, which bindings +// read so a replayed tick sees the gamepad as it was. +struct Device +{ + SDL_Gamepad* pad = nullptr; + Vec2 stick = {1, 0}; + Uint64 repeat = 0; + float axes[PAD_AXES] = {}; + Uint32 down = 0; + Uint32 presses = 0; + Uint32 taps = 0; + int held = 0; + int nav = 0; + // Edits waiting to go out, one per tick, oldest first. + int edits[EDIT_QUEUE] = {}; + int editCount = 0; + bool previous[CONTROL_COUNT] = {}; + bool isAiming = false; + bool isClaimed = false; + bool isReady = false; +}; + +// Per-frame keyboard, mouse and gamepad state. Edges persist until a tick consumes them with tick_end. Menus read it +// directly, and gameplay only sees it turned into Controls through the bindings, per device: the keyboard and mouse +// (which also take any gamepad no one has claimed), or a claimed gamepad. Mouse is in canvas pixels. Widgets drawn +// every frame use the per-frame parts instead: whether the left button was clicked since the last poll, and while a +// text field has the keyboard, the text typed, backspaces and whether Enter or Escape ended it, and scroll, the wheel +// turned since the last poll (wheel is the same for ticks, kept until one ends), whether the mouse moved, and whether +// Enter was pressed outside typing (confirming in menus). Play request is the menu's Play button, waiting for the next +// tick to start the game. Clicks, keys and buttons come from +// press events too, so one let go quicker than a frame or tick still counts. Typing hides every key from the game. F +// toggles fullscreen whenever nothing is being typed. A gamepad's right stick aims while it's in use (for the +// keyboard's device, until the mouse moves). Capture listens for the next input on one side of a control's binding +// (Escape cancels), which then doesn't count as pressed. RPG_TEST_PADS names a script of virtual gamepad presses for +// testing ("seconds pad button|axis index value" a line). +class Input +{ +public: + Binding bindings[CONTROL_COUNT]; + Device devices[DEVICE_COUNT]; + Vec2 mouse; + char typed[TYPED_LENGTH] = {}; + float scroll = 0; + float wheel = 0; + int captureControl = -1; + int erases = 0; + int slotPressed = -1; + bool isBindingChanged = false; + bool isCaptureGamepad = false; + bool isClicked = false; + bool isConfirmed = false; + bool isMouseMoved = false; + bool isPlayRequested = false; + bool isRightClicked = false; + bool isQuit = false; + bool isSubmitted = false; + // Whether the text being typed was given up on (Escape) rather than entered. + bool isCancelled = false; + + Input(); + void poll(Game& game); + void tick_end(); + void bindings_reset(); + Controls controls_get(int device, Vec2 camera, Vec2 origin, int slot, bool isMenuOpen); + void edit_send(int device, int edit); + Vec2 aim_get(int device, Vec2 camera, Vec2 origin); + bool is_down(int scancode); + bool is_pressed(int scancode); + bool is_control_pressed(Control control); + bool is_key_control_pressed(Control control); + bool is_pad_pressed(int device, int button); + Uint32 pad_presses_get(int device); + bool is_pad_tapped(int button); + int nav_get(int device); + bool is_mouse_down(MouseButton button); + bool is_mouse_pressed(MouseButton button); + bool is_mouse_released(MouseButton button); + void binding_name_get(int binding, bool isGamepad, char* text, int size); + + bool is_control_down(int device, Control control); + +private: + std::vector script; + std::vector virtualPads; + bool buttons[MOUSE_COUNT] = {}; + bool buttonsPrevious[MOUSE_COUNT] = {}; + bool keys[KEY_COUNT] = {}; + bool keysPrevious[KEY_COUNT] = {}; + bool keysTapped[KEY_COUNT] = {}; + bool presses[MOUSE_COUNT] = {}; + bool pressesUnticked[MOUSE_COUNT] = {}; + bool isSwallowed = false; + bool wasTyping = false; + + bool is_key_binding_down(int binding); + bool is_pad_binding_down(Device& device, int binding); + void capture_take(int binding); + void script_load(); + void script_play(Game& game); +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8b0b719 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,304 @@ +#include "game.h" +#ifdef __EMSCRIPTEN__ +#include +#endif +#include +#include +#include + +constexpr int WINDOW_SCALE = 2; +constexpr double MAX_FRAME_TIME = 0.25; +#ifdef SDL_PLATFORM_EMSCRIPTEN +constexpr SDL_WindowFlags WINDOW_FILL = SDL_WINDOW_FILL_DOCUMENT; +#else +constexpr SDL_WindowFlags WINDOW_FILL = 0; +#endif +constexpr float CAMERA_FOLLOW = 0.15f; + +// Where the camera's corner wants to be: this screen's living fighters' middle (or the spectated fighter) in the +// middle, clamped to the world or centred when the world is smaller. +static Vec2 camera_target_get(Game& game) +{ + Vec2 world = game.world.size_get(); + Vec2 canvas = {CANVAS_WIDTH, CANVAS_HEIGHT}; + Vec2 center = {}; + int count = 0; + for (int i = 0; i < game.fighterCount; i++) + { + if (!game.is_local(i) || !game.fighters[i].isAlive) + continue; + center = center + game.fighters[i].center_get(); + count++; + } + // Spectating, it follows whoever is being watched instead. + if (game.ui.spectated >= 0) + { + center = game.fighters[game.ui.spectated].center_get(); + count = 1; + } + Vec2 target = (count > 0 ? center * (1.0f / count) : game.local_get().center_get()) - canvas * 0.5f; + target.x = world.x > canvas.x ? std::clamp(target.x, 0.0f, world.x - canvas.x) : (world.x - canvas.x) / 2; + target.y = world.y > canvas.y ? std::clamp(target.y, 0.0f, world.y - canvas.y) : (world.y - canvas.y) / 2; + return target; +} + +// Eases the camera towards its target. +static void camera_update(Game& game) +{ + game.camera = game.camera + (camera_target_get(game) - game.camera) * CAMERA_FOLLOW; +} + +// One simulation tick: everything rollback saves and replays. Fighters hold still through a round's countdown. The +// menu is this screen's alone, so it updates before the tick (see frame_run), not in it. +void game_tick(Game& game) +{ + game.match.update(game); + if (game.match.state != MATCH_COUNTDOWN) + game.bot.update(game); + for (int i = 0; i < game.fighterCount && game.match.state != MATCH_COUNTDOWN; i++) + game.fighters[i].update(game); + game.weapons.update(game); + game.world.update(game); + game.input.tick_end(); +} + +// Steps what only this screen sees, once per live tick: particles, HUD fades and the following camera. +static void presentation_update(Game& game) +{ + game.effects.update(); + game.ui.update(game); + game.sprites.update(game); + game.cameraPrevious = game.camera; + camera_update(game); +} + +// Draws the world part way between the last two ticks. Fighters and attacks are swapped +// for blended copies while drawing, then the real simulation state is put back. +static void game_draw(Game& game) +{ + Rect boxes[MAX_FIGHTERS]; + std::vector attacks = game.weapons.attacks; + for (int i = 0; i < game.fighterCount; i++) + { + boxes[i] = game.fighters[i].body.box; + game.fighters[i].blend(game.alpha); + } + game.weapons.blend(game.alpha); + + Vec2 camera = vec2_lerp(game.cameraPrevious, game.camera, game.alpha); + if (game.settings.isShaking) + camera = camera + game.effects.shake_get(); + game.renderer.camera = {std::round(camera.x), std::round(camera.y)}; + + game.world.draw(game); + game.bot.draw(game); + game.effects.afterimage_draw(game); + game.weapons.blast_draw(game); + for (int i = 0; i < game.fighterCount; i++) + game.fighters[i].draw(game); + game.weapons.draw(game); + if (game.settings.isCollisionShown) + game.weapons.collision_draw(game); + game.effects.draw(game); + game.ui.draw(game); + game.renderer.present(); + + for (int i = 0; i < game.fighterCount; i++) + game.fighters[i].body.box = boxes[i]; + game.weapons.attacks = attacks; +} + +// Sets up everything the simulation needs: the world, the bot graph, fighters, weapons, trinkets and the seeded +// generator (RPG_SEED, every number its own odd state, or the clock). Without a renderer the sprites come back empty, +// which the simulation never needs. +static void simulation_init(Game& game) +{ + game.world.init(game); + game.bot.init(game); + for (int i = 0; i < MAX_FIGHTERS; i++) + game.fighters[i].init(game, i); + game.local_get().isBot = SDL_getenv("RPG_BOT_LOCAL"); + game.rollback.init(); + const char* seed = SDL_getenv("RPG_SEED"); + game.rng.state = seed ? (Uint32)SDL_atoi(seed) * 2 + 1 : (Uint32)SDL_GetPerformanceCounter() | 1; +} + +// The frame clock: when the last frame began, and the time not yet simulated. +struct FrameClock +{ + Uint64 previous = 0; + double accumulator = 0; +}; + +static Game game; +static FrameClock frameClock; + +// One frame: input, as many fixed ticks as the time since the last frame allows, then drawing (unless it's not to be +// drawn). Returns whether to keep going. +static bool frame_run(bool isDrawn = true) +{ + Uint64 now = SDL_GetPerformanceCounter(); + frameClock.accumulator += + std::min((now - frameClock.previous) / (double)SDL_GetPerformanceFrequency(), MAX_FRAME_TIME); + frameClock.previous = now; + + game.input.poll(game); + game.net.update(); + game.chat.update(game); + Session& session = game.session; + for (; frameClock.accumulator >= TICK; frameClock.accumulator -= TICK) + { + // Online, this screen's own work happens in the game it shows (the arena while it plays or watches a queued + // game), and the session runs the shared games, taking this screen's controls; otherwise each fighter on this + // screen takes its device's. Watching, they do nothing. + session.view_enter(game); + game.menu.update(game); + if (session.is_wanted(game)) + { + Fighter& own = game.local_get(); + bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching(); + Controls controls = + game.input.controls_get(DEVICE_KEYBOARD, game.camera, own.center_get(), own.slot, isHeld); + session.view_leave(game); + session.update(game, controls); + game.input.tick_end(); + session.view_enter(game); + if (session.isViewChanged) + { + session.isViewChanged = false; + game.camera = game.cameraPrevious = camera_target_get(game); + } + presentation_update(game); + session.view_leave(game); + continue; + } + session.view_leave(game); + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + bool isMenuOpen = game.menu.isOpen && i == game.local; + if (game.is_local(i)) + fighter.controls = game.input.controls_get(game.devices[i], game.camera, fighter.center_get(), + fighter.slot, isMenuOpen); + } + game.rollback.update(game); + presentation_update(game); + } + game.settings.update(game); + game.alpha = frameClock.accumulator / TICK; + if (isDrawn) + { + session.view_enter(game); + game_draw(game); + session.view_leave(game); + } + return !game.input.isQuit; +} + +#ifdef SDL_PLATFORM_EMSCRIPTEN +static void frame_callback() +{ + frame_run(); +} + +// A hidden tab gets no frames from the browser, but online the shared game can't wait for it: everyone else would +// stall. So a worker's timer (which the browser doesn't hold back the way it does a hidden page's own) calls this +// every 16 ms while the tab is hidden, to keep the game running, undrawn. +extern "C" EMSCRIPTEN_KEEPALIVE void frame_hidden() +{ + if (game.session.is_wanted(game)) + frame_run(false); +} +#endif + +// Runs the game in a window (on the web, filling the page and driven by the browser's frames), or with "--balance N" +// plays N bot matches headless and prints the results ("--balance N race" plays races). +// The lobby to join on starting, if any: from the page's ?lobby= in the browser, or --join on the command line. +static std::string lobby_requested(int argc, char** argv) +{ +#ifdef SDL_PLATFORM_EMSCRIPTEN + char* code = (char*)EM_ASM_PTR({ + var code = new URLSearchParams(location.search).get('lobby'); + return code ? stringToNewUTF8(code) : 0; + }); + std::string lobby = code ? code : ""; + free(code); + return lobby; +#else + for (int i = 1; i + 1 < argc; i++) + if (SDL_strcmp(argv[i], "--join") == 0) + return argv[i + 1]; + return ""; +#endif +} + +int main(int argc, char** argv) +{ + int balanceMatches = argc > 2 && SDL_strcmp(argv[1], "--balance") == 0 ? SDL_atoi(argv[2]) : 0; + if (balanceMatches > 0) + { + simulation_init(game); + bool isRace = argc > 3 && SDL_strcmp(argv[3], "race") == 0; + game.balance.matches_run(game, balanceMatches, isRace ? MODE_RACE : MODE_DEATHMATCH); + return 0; + } + + if (!SDL_Init(SDL_INIT_VIDEO)) + { + SDL_Log("Failed to init SDL: %s", SDL_GetError()); + return 1; + } + if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) + SDL_Log("Gamepads unavailable: %s", SDL_GetError()); + game.settings.load(game); + game.window = + SDL_CreateWindow(GAME_NAME, CANVAS_WIDTH * WINDOW_SCALE, CANVAS_HEIGHT * WINDOW_SCALE, + SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | WINDOW_FILL); + if (!game.window || !game.renderer.init(game.window)) + { + SDL_Log("Failed to create window: %s", SDL_GetError()); + return 1; + } + + game.text.init(game.renderer); + game.audio.init(); + game.ui.init(game); + game.sprites.init(game); + simulation_init(game); + game.renderer.atlas_upload(); + game.settings.apply(game); + game.settings.restore(game); + std::string lobby = lobby_requested(argc, argv); + if (!lobby.empty()) + { + game.net.lobby_join(game, lobby.c_str()); + game.menu.online_open(); + } + game.camera = camera_target_get(game); + game.cameraPrevious = game.camera; + SDL_HideCursor(); + + frameClock.previous = SDL_GetPerformanceCounter(); +#ifdef SDL_PLATFORM_EMSCRIPTEN + // The browser calls each frame from here on; main returns but the game lives on. + emscripten_set_main_loop(frame_callback, 0, false); + EM_ASM({ + var ticker = new Worker(URL.createObjectURL( + new Blob(["setInterval(function() { postMessage(0); }, 16);"], {type : "text/javascript"}))); + ticker.onmessage = function() + { + if (document.hidden) + _frame_hidden(); + }; + }); + return 0; +#endif + while (frame_run()) + { + } + + game.settings.save(game); + game.net.shutdown(); + SDL_Quit(); + return 0; +} diff --git a/src/match.cpp b/src/match.cpp new file mode 100644 index 0000000..7cb74e6 --- /dev/null +++ b/src/match.cpp @@ -0,0 +1,761 @@ +#include "match.h" +#include "game.h" +#include +#include +#include + +constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40}; +constexpr float RACE_GRACE = 15; +constexpr float ROUND_OVER_TIME = 2.5f; +constexpr float SECONDS_PER_MINUTE = 60; +constexpr int RACE_PLAYERS = 4; +constexpr int RACE_TIMER = 3; + +Rules rules_get(int mode) +{ + Rules rules = {.mode = mode}; + if (mode == MODE_RACE) + { + rules.players = RACE_PLAYERS; + rules.timer = RACE_TIMER; + } + return rules; +} + +// Rules from someone else (online), kept in the ranges the menu allows. Only deathmatches can be queued for now. +Rules rules_clean(Rules rules) +{ + rules.mode = MODE_DEATHMATCH; + rules.players = std::clamp(rules.players, PLAYERS_MIN, PLAYERS_MAX); + rules.bestOf = std::clamp(rules.bestOf, 1, BEST_OF_MAX) | 1; + rules.timer = std::clamp(rules.timer, 0, TIMER_MAX); + rules.health = std::clamp(rules.health, (float)BASE_HEALTH_MIN, (float)BASE_HEALTH_MAX); + if (!(rules.health == rules.health)) + rules.health = rules_get(MODE_DEATHMATCH).health; + rules.healthMultiplier = std::clamp(rules.healthMultiplier, 1.0f, MULTIPLIER_MAX); + if (!(rules.healthMultiplier == rules.healthMultiplier)) + rules.healthMultiplier = 1; + for (ItemRules* items : {&rules.weapons, &rules.trinkets}) + { + items->slots = std::clamp(items->slots, 1, SLOT_MAX); + items->period = std::clamp(items->period, PERIOD_MIN, PERIOD_MAX); + items->startLevel = std::clamp(items->startLevel, 1, LEVEL_COUNT); + items->timing = std::clamp(items->timing, (int)PICK_FIRST_ROUND, (int)PICK_PERIOD); + } + return rules; +} + +// Fills a loadout's empty slots with random picks out of count items, none repeating what's already in it. +static void picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDuplicates) +{ + for (int i = 0; i < slots; i++) + { + if (loadout[i] >= 0) + continue; + int pick = rng.random_get(0, count); + while (!isDuplicates && std::find(loadout, loadout + slots, pick) != loadout + slots) + pick = rng.random_get(0, count); + loadout[i] = pick; + } +} + +// Whether a round hands out this kind of item: the first round of the match always, every round, or every period. +static bool is_pick_round(ItemRules& items, int round, bool isFirst) +{ + return isFirst || items.timing == PICK_EVERY_ROUND || (items.timing == PICK_PERIOD && round % items.period == 0); +} + +static int level_get(ItemRules& items, int round) +{ + return std::clamp(items.startLevel - 1 + (items.isLevelUp ? round : 0), 0, LEVEL_COUNT - 1); +} + +void Match::update(Game& game) +{ + timer -= TICK; + // The menu's Play starts a game, and Escape (or B) leaves the results for the sandbox, unless it's just closed the + // menu. + Input& input = game.input; + bool isOver = state == MATCH_OVER; + // Online, this screen's keys can't change the shared game (starting matches online is the host's, through the + // session). + bool isLocal = !game.session.isActive; + bool isPlay = isLocal && input.isPlayRequested; + bool isSandbox = isLocal && isOver && !game.menu.isOpen && !game.menu.isEscapeTaken && + (input.is_pressed(SDL_SCANCODE_ESCAPE) || input.is_pad_tapped(SDL_GAMEPAD_BUTTON_EAST)); + + if (state != MATCH_PRACTICE) + leaves_take(game); + switch (state) + { + case MATCH_PRACTICE: + case MATCH_OVER: + // Online, after a queued game, Ready says to play it again (see Session). + for (int i = 0; i < game.fighterCount && state == MATCH_OVER && game.session.isActive; i++) + if (game.fighters[i].is_pressed(CONTROL_READY) && !isGone[i]) + { + isReady[i] = !isReady[i]; + game.audio.play(isReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); + } + // In the sandbox, every fighter's edits land, and Ready (R) puts a fighter back at its spawn, still, with + // its attacks cleared away. (Keys typed into a text field never reach the controls.) + // Online, Ready in a queued game's area readies up for it instead. + if (state == MATCH_PRACTICE) + queues_update(game); + for (int i = 0; i < game.fighterCount && state == MATCH_PRACTICE; i++) + { + Fighter& fighter = game.fighters[i]; + edit_take(game, fighter); + if (!fighter.is_pressed(CONTROL_READY)) + continue; + if (queuedIn[i] >= 0) + { + isQueueReady[i] = !isQueueReady[i]; + game.audio.play(isQueueReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); + continue; + } + fighter.position_reset(game); + std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + } + if (isPlay) + start(game); + else if (isSandbox && state == MATCH_OVER) + sandbox_return(game); + break; + case MATCH_COUNTDOWN: + { + // Ready sticks once pressed, and bots always are. Once everyone is, the wait drops to its last second. + // Fighters choosing items pick them through their controls. + bool isEveryone = true; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + edit_take(game, fighter); + isReady[i] |= fighter.isBot || fighter.is_down(CONTROL_READY); + isEveryone &= isReady[i]; + } + if (isEveryone) + timer = std::min(timer, COUNTDOWN_TIME); + if (timer <= 0) + fight_start(game); + break; + } + case MATCH_FIGHT: + clock += TICK; + if (rules.mode == MODE_RACE) + race_update(game); + else + deathmatch_update(game); + break; + case MATCH_ROUND_OVER: + if (timer > 0) + break; + if (winner >= 0 && wins[winner] >= rules.wins_get()) + { + state = MATCH_OVER; + std::fill(std::begin(isReady), std::end(isReady), false); + break; + } + round += winner >= 0; + round_start(game, false); + break; + } +} + +// Loads the arena (without the dummy) or builds a fresh course, maps it for the bots, sets everyone's slots to the +// rules' and starts the first round. In single player, every fighter but this screen's is a bot. +void Match::start(Game& game) +{ + if (state == MATCH_PRACTICE) + sandbox = game.local_get(); + winner = -1; + game.fighterCount = rules.players; + if (rules.mode == MODE_RACE) + game.world.course_build(game.rng); + else + game.world.arena_load(); + game.bot.graph_build(game.world); + std::fill(std::begin(wins), std::end(wins), 0); + round = 0; + + // Online (a queued game), the bots are whoever no member plays, and everyone's colour was settled already. + bool isOnline = game.session.isActive; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + fighter.isBot = isOnline ? owners[i] < 0 : game.devices[i] == DEVICE_NONE || (i == game.local && fighter.isBot); + fighter.slots_set(rules.weapons.slots, rules.trinkets.slots); + } + if (!isOnline) + colors_assign(game); + // The menu closes for whoever plays it on this screen (online, a queued game starting doesn't close a bystander's). + if (game.local_count() > 0) + game.menu.isOpen = false; + round_start(game, true); +} + +// Another player on this screen joins the sandbox on a gamepad, starting from this screen's sandbox loadout and +// settings, with PvP off. +void Match::sandbox_join(Game& game, int device) +{ + if (game.fighterCount >= MAX_FIGHTERS) + return; + int index = game.fighterCount++; + Fighter& fighter = game.fighters[index]; + fighter = game.local_get(); + fighter.init(game, index); + Fighter& host = game.local_get(); + std::copy(std::begin(host.weaponLoadout), std::end(host.weaponLoadout), fighter.weaponLoadout); + std::copy(std::begin(host.trinketLoadout), std::end(host.trinketLoadout), fighter.trinketLoadout); + fighter.isPvp = false; + fighter.controls = {}; + game.devices[index] = device; + game.input.devices[device].isClaimed = true; + colors_assign(game); + game.audio.play(SOUND_EQUIP); +} + +// A fighter leaves the sandbox with its attacks, and the ones after it move down a place. +void Match::sandbox_leave(Game& game, int index) +{ + int device = game.devices[index]; + if (device > DEVICE_KEYBOARD) + game.input.devices[device].isClaimed = false; + std::vector& attacks = game.weapons.attacks; + std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); + for (Attack& attack : attacks) + attack.owner -= attack.owner > index; + for (int i = index; i + 1 < game.fighterCount; i++) + { + game.fighters[i] = game.fighters[i + 1]; + game.fighters[i].index = i; + game.devices[i] = game.devices[i + 1]; + } + game.fighterCount--; + game.devices[game.fighterCount] = DEVICE_NONE; + colors_assign(game); + game.audio.play(SOUND_UNEQUIP); +} + +// Gives every fighter a colour of their own: the one they asked for if it's free (the earlier fighter keeps it if two +// ask for the same), then the rest in palette order. Only this screen's player asks for now. +void Match::colors_assign(Game& game) +{ + int wanted[MAX_FIGHTERS]; + std::fill(std::begin(wanted), std::end(wanted), COLOR_UNASSIGNED); + wanted[game.local] = game.settings.color_get(); + bool isTaken[COLOR_COUNT] = {}; + for (int i = 0; i < MAX_FIGHTERS; i++) + { + colors[i] = COLOR_UNASSIGNED; + if (wanted[i] >= 0 && !isTaken[wanted[i]]) + { + colors[i] = wanted[i]; + isTaken[wanted[i]] = true; + } + } + for (int i = 0; i < MAX_FIGHTERS; i++) + { + if (colors[i] >= 0) + continue; + colors[i] = std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken; + isTaken[colors[i]] = true; + } +} + +// Sets everyone's levels and health for the round, and hands out items if it's their round: dealt at random, or +// emptied for choosing in the menu (bots are dealt theirs) with the menu opened to them. +void Match::round_start(Game& game, bool isFirst) +{ + isWeaponPick = is_pick_round(rules.weapons, round, isFirst); + isTrinketPick = is_pick_round(rules.trinkets, round, isFirst); + game.weapons.attacks.clear(); + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + fighter.levels_fill(level_get(rules.weapons, round), level_get(rules.trinkets, round)); + // Mirrored items are dealt once, to the first fighter, and copied to the rest below. Bots choose nothing then. + if (isWeaponPick) + { + std::fill(fighter.weaponLoadout, fighter.weaponLoadout + fighter.weaponSlots, -1); + bool isDealt = + rules.weapons.isMirror ? i == 0 && !rules.weapons.isChoice : !rules.weapons.isChoice || fighter.isBot; + if (isDealt) + picks_deal(game.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, + rules.weapons.isDuplicates); + } + if (isTrinketPick) + { + std::fill(fighter.trinketLoadout, fighter.trinketLoadout + fighter.trinketSlots, -1); + bool isDealt = rules.trinkets.isMirror ? i == 0 && !rules.trinkets.isChoice + : !rules.trinkets.isChoice || fighter.isBot; + if (isDealt) + picks_deal(game.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, + rules.trinkets.isDuplicates); + } + fighter.state_reset(game, health_get()); + // Someone who left a queued game stays out of it. + fighter.isAlive &= !isGone[i]; + } + if (isWeaponPick && rules.weapons.isMirror) + mirror_copy(game, false); + if (isTrinketPick && rules.trinkets.isMirror) + mirror_copy(game, true); + isWeaponPick &= rules.weapons.isChoice; + isTrinketPick &= rules.trinkets.isChoice; + if ((isWeaponPick || isTrinketPick) && game.local_count() == 1) + game.menu.choice_open(game); + + std::fill(std::begin(isReady), std::end(isReady), false); + std::fill(std::begin(finishTimes), std::end(finishTimes), -1.0f); + clock = 0; + state = MATCH_COUNTDOWN; + // Choosing takes longer the more there is to choose: each kind being chosen adds time for every slot past its + // first. + countdown = READY_TIME; + if (isWeaponPick) + countdown += CHOICE_SLOT_TIME * (rules.weapons.slots - 1); + if (isTrinketPick) + countdown += CHOICE_SLOT_TIME * (rules.trinkets.slots - 1); + timer = countdown; +} + +// Applies the edit in a fighter's controls, if any (see Edit). In a match only Choice picks count: an item goes into +// its slot as long as that kind is being chosen and the slot is in use, leaving any other slot it was in unless +// duplicates are allowed, and mirrored items go into every fighter's slot. The sandbox takes every kind of edit. +void Match::edit_take(Game& game, Fighter& fighter) +{ + int code = fighter.controls.edit; + if (code < 0) + return; + fighter.controls.edit = -1; + Edit edit = edit_decode(code); + bool isSandbox = state == MATCH_PRACTICE; + bool isTrinket = edit.isTrinket; + int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int items = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int level = std::clamp(edit.value, 0, LEVEL_COUNT - 1); + switch (edit.type) + { + case EDIT_ITEM: + { + bool isPicking = isSandbox || (state == MATCH_COUNTDOWN && (isTrinket ? isTrinketPick : isWeaponPick)); + int item = edit.value == EDIT_EMPTY ? -1 : edit.value; + if (!isPicking || edit.index >= count || item >= items) + return; + if (isSandbox) + { + fighter.item_set(isTrinket, edit.index, item); + return; + } + ItemRules& kind = isTrinket ? rules.trinkets : rules.weapons; + for (int i = 0; i < count && !kind.isDuplicates && item >= 0; i++) + loadout[i] = loadout[i] == item ? -1 : loadout[i]; + loadout[edit.index] = item; + if (kind.isMirror) + for (int i = 0; i < game.fighterCount; i++) + std::copy(loadout, loadout + count, + isTrinket ? game.fighters[i].trinketLoadout : game.fighters[i].weaponLoadout); + return; + } + case EDIT_SLOT_LEVEL: + if (isSandbox && edit.index < count && loadout[edit.index] >= 0) + { + int& copy = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[edit.index]; + game.ui.level_step(game, copy, level - copy); + } + return; + case EDIT_ITEM_LEVEL: + { + if (!isSandbox || edit.index >= items) + return; + int& itemLevel = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[edit.index]; + game.ui.level_step(game, itemLevel, level - itemLevel); + for (int i = 0; i < SLOT_MAX; i++) + if (loadout[i] == edit.index) + (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i] = itemLevel; + return; + } + case EDIT_SLOTS: + if (isSandbox) + fighter.slots_set(std::clamp(edit.index, 1, SLOT_MAX), std::clamp(edit.value, 1, SLOT_MAX)); + return; + case EDIT_PVP: + if (isSandbox) + fighter.isPvp = edit.value != 0; + return; + case EDIT_COLOR: + { + bool isTaken = edit.value >= COLOR_COUNT; + for (int i = 0; i < game.fighterCount; i++) + isTaken |= i != fighter.index && colors[i] == edit.value; + if (isSandbox && !isTaken) + colors[fighter.index] = edit.value; + return; + } + } +} + +// Copies the first fighter's loadout of one kind of item to every other fighter. +void Match::mirror_copy(Game& game, bool isTrinket) +{ + Fighter& first = game.fighters[0]; + for (int i = 1; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (isTrinket) + std::copy(first.trinketLoadout, first.trinketLoadout + SLOT_MAX, fighter.trinketLoadout); + else + std::copy(first.weaponLoadout, first.weaponLoadout + SLOT_MAX, fighter.weaponLoadout); + } +} + +// The countdown is over: slots still empty after choosing are dealt at random (once, and shared, when mirrored), and +// the menu closes. +void Match::fight_start(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + picks_deal(game.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, rules.weapons.isDuplicates); + picks_deal(game.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, rules.trinkets.isDuplicates); + if (i == 0 && rules.weapons.isMirror) + mirror_copy(game, false); + if (i == 0 && rules.trinkets.isMirror) + mirror_copy(game, true); + } + if ((isWeaponPick || isTrinketPick) && game.local_count() > 0) + game.menu.isOpen = false; + isWeaponPick = false; + isTrinketPick = false; + clock = 0; + state = MATCH_FIGHT; +} + +// The round ends with one fighter (or none) left standing, or when time runs out with whoever has the most health, +// drawn if that's shared. +void Match::deathmatch_update(Game& game) +{ + int alive = 0; + winner = -1; + for (int i = 0; i < game.fighterCount; i++) + { + alive += game.fighters[i].isAlive; + winner = game.fighters[i].isAlive ? i : winner; + } + bool isTimeUp = time_limit_get() > 0 && clock >= time_limit_get(); + if (alive > 1 && !isTimeUp) + return; + + if (alive > 1) + { + float best = 0; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (!fighter.isAlive || fighter.health < best) + continue; + winner = fighter.health == best ? -1 : i; + best = fighter.health; + } + } + if (winner >= 0) + wins[winner]++; + state = MATCH_ROUND_OVER; + timer = ROUND_OVER_TIME; + game.audio.play(SOUND_FINISH); +} + +// Whether the coming deathmatch round decides the match whoever wins it: every fighter is a win short of taking it. +bool Match::is_final_round(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + if (wins[i] != rules.wins_get() - 1) + return false; + return rules.mode != MODE_RACE; +} + +// A round's health: the rules' for the first, multiplied for each round after. +float Match::health_get() +{ + return rules.health * math_pow(rules.healthMultiplier, round); +} + +// A round's time limit in seconds, or 0 for none. +float Match::time_limit_get() +{ + return rules.timer * SECONDS_PER_MINUTE; +} + +// Whether hits only knock fighters about: any part of a race. +bool Match::is_racing() +{ + return rules.mode == MODE_RACE && state != MATCH_PRACTICE; +} + +// Sees fighters over the finish line, then ends the race once nobody is left running, a while after the winner +// finished, or when time runs out, when the racer furthest along wins if nobody has finished. +void Match::race_update(Game& game) +{ + int running = 0; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (fighter.isAlive && game.world.is_touching(fighter.body.box, TILE_FINISH)) + { + finishTimes[i] = clock; + winner = winner < 0 ? i : winner; + fighter.isAlive = false; + fighter.charge_cancel(game); + for (Attack& attack : game.weapons.attacks) + attack.isDead |= attack.owner == i; + game.effects.burst(FINISH_BURST, fighter.center_get(), 0, game.color_get(i)); + game.audio.play(SOUND_FINISH); + } + running += fighter.isAlive; + } + + bool isGraceOver = winner >= 0 && clock > finishTimes[winner] + RACE_GRACE; + bool isTimeUp = time_limit_get() > 0 && clock > time_limit_get(); + if (running > 0 && !isGraceOver && !isTimeUp) + return; + float furthest = -INFINITY; + for (int i = 0; i < game.fighterCount && finishTimes[std::max(0, winner)] < 0; i++) + { + float x = game.fighters[i].center_get().x; + if (game.fighters[i].isAlive && x > furthest) + { + winner = i; + furthest = x; + } + } + state = MATCH_OVER; +} + +// Back to the sandbox: the arena and its dummy, the local fighter alone with its own slots, loadout and levels again. +void Match::sandbox_return(Game& game) +{ + state = MATCH_PRACTICE; + isWeaponPick = false; + isTrinketPick = false; + game.fighterCount = 1; + game.world.sandbox_load(); + game.bot.graph_build(game.world); + game.weapons.attacks.clear(); + game.menu.isOpen = false; + + Fighter& fighter = game.local_get(); + fighter.slots_set(sandbox.weaponSlots, sandbox.trinketSlots); + std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout); + std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout); + std::copy(std::begin(sandbox.weaponItemLevels), std::end(sandbox.weaponItemLevels), fighter.weaponItemLevels); + std::copy(std::begin(sandbox.trinketItemLevels), std::end(sandbox.trinketItemLevels), fighter.trinketItemLevels); + std::copy(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), fighter.weaponSlotLevels); + std::copy(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), fighter.trinketSlotLevels); + fighter.state_reset(game, 1); +} + +// Online, opens a fighter's queued game: an area on the ground big enough for everyone it's for, as near the fighter +// as there's room (on the floor below it, or the nearest stretch of floor to either side that's flat, clear overhead +// and not another queue's), with the fighter standing still at its left end, readied up. +void Match::queue_open(Game& game, int fighter, const Rules& wanted) +{ + World& world = game.world; + Queue& queue = queues[fighter]; + queue = {.rules = rules_clean(wanted), .isActive = true}; + float width = queue.rules.players * QUEUE_SLOT_WIDTH + QUEUE_MARGIN * 2; + Vec2 size = world.size_get(); + Fighter& queuer = game.fighters[fighter]; + Vec2 feet = {queuer.body.box.x + queuer.body.box.w / 2, queuer.body.box.y + queuer.body.box.h}; + + // The top of the first floor at or below a point, if there's one. + auto floor_find = [&](float x, float from) { + for (float y = std::max(from, 1.0f); y < size.y; y++) + if (world.is_blocking_at({x, y}) && !world.is_blocking_at({x, y - 1})) + return y; + return -1.0f; + }; + auto is_fitting = [&](float left, float floor) { + if (left < 0 || left + width > size.x || floor < QUEUE_HEIGHT) + return false; + for (float x = left; x <= left + width; x += QUEUE_PROBE_STEP) + { + if (!world.is_blocking_at({x, floor + 1}) || world.is_blocking_at({x, floor - 1})) + return false; + for (float up = QUEUE_PROBE_STEP; up <= QUEUE_HEIGHT; up += QUEUE_PROBE_STEP) + if (world.is_solid_at({x, floor - up})) + return false; + } + Rect area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; + for (int i = 0; i < game.fighterCount; i++) + if (i != fighter && queues[i].isActive && is_rect_overlapping(area, queues[i].area)) + return false; + return true; + }; + float start = std::round(feet.x - QUEUE_MARGIN - QUEUE_SLOT_WIDTH / 2); + queue.area = {start, feet.y - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; + for (float offset = 0; offset < size.x; offset += QUEUE_PROBE_STEP) + { + bool isFound = false; + for (float left : {start + offset, start - offset}) + { + float floor = floor_find(left + width / 2, std::round(feet.y - TILE_SIZE)); + if (floor < 0 || !is_fitting(left, floor)) + continue; + queue.area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; + isFound = true; + break; + } + if (isFound) + break; + } + queuer.position_set(game, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h}); + std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; }); + queuedIn[fighter] = fighter; + isQueueReady[fighter] = false; + game.audio.play(SOUND_EQUIP); +} + +// Who's in a queued game's area: the queuer first, then the rest in fighter order. Returns how many. +int Match::queue_members_get(Game& game, int queuer, int* members) +{ + int count = 0; + members[count++] = queuer; + for (int i = 0; i < game.fighterCount; i++) + if (i != queuer && queuedIn[i] == queuer) + members[count++] = i; + return count; +} + +// Whether a queued game's area has everyone it's for. +bool Match::is_queue_full(Game& game, int queuer) +{ + int members[MAX_FIGHTERS]; + return queue_members_get(game, queuer, members) >= queues[queuer].rules.players; +} + +// Whether a queued game is set to start: everyone in its area has readied up, and either it's full or it fills its +// empty places with bots (and isn't over full). The queuer readying is what starts one with bots. +bool Match::is_queue_set(Game& game, int queuer) +{ + Queue& queue = queues[queuer]; + if (!queue.isActive || isAway[queuer]) + return false; + int members[MAX_FIGHTERS]; + int count = queue_members_get(game, queuer, members); + bool isReadied = true; + for (int i = 0; i < count; i++) + isReadied &= isQueueReady[members[i]]; + return isReadied && (count == queue.rules.players || (queue.rules.isBotFill && count < queue.rules.players)); +} + +// The queued game to start now, if any: one that's been set to for a moment (so a change of mind can stop it, and +// the Ready that set it is let go before its countdown, which would take it). +int Match::queue_startable_get(Game& game) +{ + for (int queuer = 0; queuer < game.fighterCount; queuer++) + if (is_queue_set(game, queuer) && queues[queuer].readyTicks >= QUEUE_START_TICKS) + return queuer; + return -1; +} + +// Moves the per-fighter queue state down a place from a fighter leaving, so it stays with its fighters. +void Match::queue_slots_shift(int from, int count) +{ + for (int i = from; i + 1 < count; i++) + { + queues[i] = queues[i + 1]; + isAway[i] = isAway[i + 1]; + isGone[i] = isGone[i + 1]; + } + if (count > 0) + { + queues[count - 1] = {}; + isAway[count - 1] = false; + isGone[count - 1] = false; + } + std::fill(std::begin(queuedIn), std::end(queuedIn), -1); + std::fill(std::begin(isQueueReady), std::end(isQueueReady), false); +} + +// Who stands in which queued game's area this tick (anyone away playing stands in none, and a queuer always in its +// own). Stepping out unreadies. A queuer can't move or attack (only crouch, and ready up), and anyone away does +// nothing. +void Match::queues_update(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (isAway[i]) + { + fighter.controls = {}; + queuedIn[i] = -1; + isQueueReady[i] = false; + continue; + } + int in = -1; + if (queues[i].isActive) + in = i; + Vec2 feet = {fighter.body.box.x + fighter.body.box.w / 2, fighter.body.box.y + fighter.body.box.h}; + for (int j = 0; j < game.fighterCount && in < 0; j++) + { + Rect area = queues[j].area; + area = {area.x - QUEUE_FEET_SLACK, area.y, area.w + QUEUE_FEET_SLACK * 2, area.h + QUEUE_FEET_SLACK}; + if (queues[j].isActive && !isAway[j] && fighter.isAlive && is_point_in_rect(feet, area)) + in = j; + } + if (in != queuedIn[i]) + isQueueReady[i] = false; + queuedIn[i] = in; + if (queues[i].isActive) + { + Uint16 kept = 1 << CONTROL_DOWN | 1 << CONTROL_READY; + fighter.controls.buttons &= kept; + } + } + for (int i = 0; i < game.fighterCount; i++) + queues[i].readyTicks = is_queue_set(game, i) ? queues[i].readyTicks + 1 : 0; +} + +// In a queued game, how many players (not bots) haven't left it. +int Match::humans_left_get(Game& game) +{ + int count = 0; + for (int i = 0; i < game.fighterCount; i++) + count += owners[i] >= 0 && !isGone[i]; + return count; +} + +// Online, in a queued game, a player leaving it (their Leave edit): they're out for good, which counts as a KO. With +// only one player left after that, they win the match outright. +void Match::leaves_take(Game& game) +{ + bool isLeft = false; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (fighter.controls.edit < 0 || edit_decode(fighter.controls.edit).type != EDIT_LEAVE) + continue; + fighter.controls.edit = -1; + if (isGone[i]) + continue; + isGone[i] = true; + isForfeited[i] = state != MATCH_OVER; + isReady[i] = false; + fighter.isAlive = false; + std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + isLeft = true; + } + if (!isLeft || state == MATCH_OVER) + return; + int left = -1; + for (int i = 0; i < game.fighterCount; i++) + left = owners[i] >= 0 && !isGone[i] ? (left == -1 ? i : -2) : left; + if (left == -2) + return; + winner = left; + state = MATCH_OVER; + std::fill(std::begin(isReady), std::end(isReady), false); + game.audio.play(SOUND_FINISH); +} diff --git a/src/match.h b/src/match.h new file mode 100644 index 0000000..2e6e21a --- /dev/null +++ b/src/match.h @@ -0,0 +1,229 @@ +#pragma once + +#include "fighter.h" +#include "protocol.h" + +struct Game; + +constexpr int BEST_OF_MAX = 9; +constexpr int PLAYERS_MAX = 4; +constexpr int PLAYERS_MIN = 2; +constexpr int TIMER_MAX = 10; +// A queued game's area: room per player, a margin each end, its height, how finely the ground's checked for one, and +// how far below its floor feet still count as in it. +constexpr float QUEUE_SLOT_WIDTH = 24; +constexpr float QUEUE_MARGIN = 6; +constexpr float QUEUE_HEIGHT = 40; +constexpr float QUEUE_PROBE_STEP = 4; +constexpr float QUEUE_FEET_SLACK = 3; +constexpr int QUEUE_START_TICKS = 60; +constexpr int BASE_HEALTH_MAX = 10000; +constexpr int BASE_HEALTH_MIN = 10; +constexpr int PERIOD_MAX = 10; +constexpr int PERIOD_MIN = 2; +constexpr float MULTIPLIER_MAX = 5; +constexpr float COUNTDOWN_TIME = 1; +constexpr float READY_TIME = 15; +constexpr float CHOICE_SLOT_TIME = 5; + +enum MatchMode +{ + MODE_DEATHMATCH, + MODE_RACE +}; + +enum MatchState +{ + MATCH_PRACTICE, + MATCH_COUNTDOWN, + MATCH_FIGHT, + MATCH_ROUND_OVER, + MATCH_OVER +}; + +// When a match hands out items: once at its start, every round, or every period rounds. +enum PickTiming +{ + PICK_FIRST_ROUND, + PICK_EVERY_ROUND, + PICK_PERIOD +}; + +// How a match hands out one kind of item: how many slots, dealt at random or chosen in the menu, on which rounds, the +// level they start at (1 to 3), whether they gain one each round, whether every fighter mirrors one loadout, whether +// a loadout can hold the same item more than once (copies always stack; see Fighter). +struct ItemRules +{ + int period = 2; + int slots = SLOTS_DEFAULT; + int startLevel = 1; + int timing = PICK_FIRST_ROUND; + bool isChoice = true; + bool isDuplicates = false; + bool isLevelUp = true; + bool isMirror = false; + + bool operator==(const ItemRules&) const = default; +}; + +// A match's settings, picked in the menu's Play tab. A deathmatch is best of an odd number of rounds, won by taking +// more than half of them. Health is the first round's, multiplied by the multiplier for each round after. Timer is a +// round's limit in minutes (0 for none). Single player fills every other slot with bots, and bot fill only decides it +// in multiplayer. +struct Rules +{ + ItemRules trinkets; + ItemRules weapons; + float health = 250; + float healthMultiplier = 2; + int bestOf = 3; + int mode = MODE_DEATHMATCH; + int players = PLAYERS_MIN; + int timer = 0; + bool isBotFill = false; + + bool operator==(const Rules&) const = default; + int wins_get() const + { + return bestOf / 2 + 1; + } +}; + +// A change a player makes to their own fighter from the menu, sent through its controls so that every machine applies +// it on the same tick (and rollback replays it). Item puts an item (or EDIT_EMPTY) in a slot, and is the only kind a +// match takes, as a Choice pick; the rest are the sandbox's: one copy's level, an item's level (with every copy of it +// equipped), the slot counts (index weapons, value trinkets), PvP on or off, and online, the colour (if no one else has +// it). +enum EditType +{ + EDIT_ITEM, + EDIT_SLOT_LEVEL, + EDIT_ITEM_LEVEL, + EDIT_SLOTS, + EDIT_PVP, + EDIT_COLOR, + // Online, leaving a queued game (forfeiting it if it's still on). + EDIT_LEAVE +}; + +struct Edit +{ + int type = EDIT_ITEM; + bool isTrinket = false; + int index = 0; + int value = 0; +}; + +constexpr int EDIT_EMPTY = 31; +constexpr int EDIT_FIELD_BITS = 5; +constexpr int EDIT_FIELD_MASK = (1 << EDIT_FIELD_BITS) - 1; +constexpr int EDIT_KIND_SHIFT = EDIT_FIELD_BITS * 2; +constexpr int EDIT_TYPE_SHIFT = EDIT_KIND_SHIFT + 1; + +// An edit packed into one number for a fighter's controls (-1 is none). +inline int edit_encode(Edit edit) +{ + return (edit.value & EDIT_FIELD_MASK) | (edit.index & EDIT_FIELD_MASK) << EDIT_FIELD_BITS | + edit.isTrinket << EDIT_KIND_SHIFT | edit.type << EDIT_TYPE_SHIFT; +} + +inline Edit edit_decode(int code) +{ + return {.type = code >> EDIT_TYPE_SHIFT, + .isTrinket = (code >> EDIT_KIND_SHIFT & 1) != 0, + .index = code >> EDIT_FIELD_BITS & EDIT_FIELD_MASK, + .value = code & EDIT_FIELD_MASK}; +} + +// Putting an item in a slot (or emptying it, for an item below 0). +inline int pick_encode(bool isTrinket, int slot, int item) +{ + return edit_encode( + {.type = EDIT_ITEM, .isTrinket = isTrinket, .index = slot, .value = item < 0 ? EDIT_EMPTY : item}); +} + +// The default rules for a mode: a best-of-three deathmatch for two, or a three minute race for four. +Rules rules_get(int mode); +Rules rules_clean(Rules rules); + +// Online, a game queued in the lobby's sandbox by one of its fighters: its rules, and the area on the ground where +// whoever wants to play it gathers (the queuer standing at its left). +struct Queue +{ + Rules rules; + Rect area; + // How many ticks it's been ready to start for (it starts after QUEUE_START_TICKS). + int readyTicks = 0; + bool isActive = false; +}; + +// Best-of-three deathmatch, or a single race, played by the rules. Practice is the sandbox with the dummy and the menu. +// Each round opens with a long countdown to ready up in (cut short once every fighter is ready), at the round's level +// and health. Countdown is how long it ran from: longer on rounds where fighters choose items. On the rounds items are +// handed out, they're dealt at random, or chosen in the menu during the countdown with any slots left empty dealt at +// random when it ends. A deathmatch round goes to the last fighter standing, or when the timer runs out to whoever has +// the most health left. A drawn round is replayed. A race is won by the first over the finish line (finishers leave the +// course), ending once everyone is over, a while after the winner, or when the timer runs out (then the one furthest +// along wins if nobody finished). Forfeiting, or Escape after a match, goes back to the sandbox with the loadout and +// levels from before it. Enter after a match plays again. +class Match +{ +public: + Rules rules = rules_get(MODE_DEATHMATCH); + MatchState state = MATCH_PRACTICE; + float clock = 0; + float countdown = READY_TIME; + float finishTimes[MAX_FIGHTERS] = {}; + float timer = 0; + int round = 0; + int winner = -1; + int wins[MAX_FIGHTERS] = {}; + int colors[MAX_FIGHTERS] = {0, 1, 2, 3}; + // Online, which lobby member plays each fighter (-1 for none) and their name, kept with the game so every machine + // (and a player joining) agrees who's who as fighters come and go. + int owners[MAX_FIGHTERS] = {-1, -1, -1, -1}; + char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1] = {}; + bool isReady[MAX_FIGHTERS] = {}; + // Online, in the lobby's sandbox: each fighter's queued game, which queue's area it's standing in (-1 for none), + // whether it's readied up there, and whether it's away playing a queued game (faded, and left alone meanwhile). + // In a queued game, whether each fighter has left it (out for good), and whether that was before it was over. + Queue queues[MAX_FIGHTERS]; + int queuedIn[MAX_FIGHTERS] = {-1, -1, -1, -1}; + bool isQueueReady[MAX_FIGHTERS] = {}; + bool isAway[MAX_FIGHTERS] = {}; + bool isGone[MAX_FIGHTERS] = {}; + bool isForfeited[MAX_FIGHTERS] = {}; + // After a queued game, how many ticks everyone's been set to play it again for (see Session). + int againTicks = 0; + bool isTrinketPick = false; + bool isWeaponPick = false; + Fighter sandbox; + + void update(Game& game); + void start(Game& game); + void colors_assign(Game& game); + void sandbox_return(Game& game); + void sandbox_join(Game& game, int device); + void sandbox_leave(Game& game, int fighter); + bool is_racing(); + float health_get(); + bool is_final_round(Game& game); + float time_limit_get(); + void queue_open(Game& game, int fighter, const Rules& rules); + int queue_members_get(Game& game, int queuer, int* members); + bool is_queue_full(Game& game, int queuer); + int queue_startable_get(Game& game); + bool is_queue_set(Game& game, int queuer); + void queue_slots_shift(int from, int count); + int humans_left_get(Game& game); + +private: + void round_start(Game& game, bool isFirst); + void fight_start(Game& game); + void edit_take(Game& game, Fighter& fighter); + void mirror_copy(Game& game, bool isTrinket); + void deathmatch_update(Game& game); + void race_update(Game& game); + void queues_update(Game& game); + void leaves_take(Game& game); +}; diff --git a/src/menu.cpp b/src/menu.cpp new file mode 100644 index 0000000..c8ed85c --- /dev/null +++ b/src/menu.cpp @@ -0,0 +1,1120 @@ +#include "menu.h" +#include +#include +#ifdef __EMSCRIPTEN__ +#include +#endif +#include "game.h" +#include +#include +#include + +constexpr float BUTTON_WIDTH = 120; +constexpr float CODE_FIELD_WIDTH = 60; +constexpr float CODE_SCALE = 2; +constexpr float JOIN_BUTTON_WIDTH = 40; +constexpr float COPY_BUTTON_WIDTH = 80; +constexpr Uint64 COPIED_SHOWN = 1500; +constexpr const char LOBBY_PAGE_DEFAULT[] = "https://shweetz.net/smack-a-brother/"; +constexpr float LEVEL_ROW_GAP = 4; +constexpr float CRUMB_GAP = 8; +constexpr float CRUMB_HEIGHT = 20; +constexpr float DESCRIPTION_WIDTH = 210; +constexpr float DIM_ALPHA = 0.6f; +constexpr float DIVIDER = 2; +constexpr float STICK_DEADZONE = 0.3f; +constexpr float STICK_SCROLL = 240; +constexpr float HEADER_BUTTON_WIDTH = 92; +constexpr float LABEL_WIDTH = 136; +constexpr float NUMBER_WIDTH = 84; +constexpr float PADDING = 8; +constexpr float PLAY_HEIGHT = 22; +constexpr float RADIO_GAP = 12; +constexpr float ROW_HEIGHT = 18; +constexpr float TEXT_ROW_INSET = 1; +constexpr int LABEL_LENGTH = 64; +constexpr float SCROLL_HEAD_MIN = 8; +constexpr float SCROLL_WIDTH = 6; +constexpr float TAB_SCALE = 2; +constexpr float TAB_HOVER_ALPHA = 0.35f; +constexpr int BASE_HEALTH_STEP = 50; +constexpr float MULTIPLIER_STEP = 0.25f; +constexpr const char* TAB_NAMES[TAB_COUNT] = {"Weapons", "Trinkets", "Play", "Customize", "Settings"}; +constexpr const char* MODE_NAMES[] = {"Deathmatch", "Race"}; +constexpr const char* PICK_NAMES[] = {"Random", "Choice"}; +constexpr const char* TIMING_NAMES[] = {"First Round", "Every Round", "Period"}; + +// The header's tab strip: the whole header, less room on the right for the match's buttons. +static Rect tab_area_get(Game& game, Rect header) +{ + bool isMatch = game.match.state != MATCH_PRACTICE; + return {header.x, header.y, header.w - (isMatch ? HEADER_BUTTON_WIDTH * 2 : 0), header.h}; +} + +// The Menu control opens and closes it. While open: click a tab to switch to it. On an item tab, click an item to +// select it, and click it again to put it in the target slot (then the target moves on to the next empty slot); click +// one of the tab's hotbar slots to make it the target. A slot's number key assigns the selection to it directly. +// Assigning an item to the slot it's already in clears it, and right-clicking any filled slot clears it. In a match, +// only the kind being chosen can be filled; clicking a slot that can't be filled from this tab shows its item in the +// description. In the +// sandbox, the Level Down/Up controls (their keyboard side) level the selection, or the copy in the hotbar slot under +// the mouse. The other tabs handle themselves while drawing. +void Menu::update(Game& game) +{ + Input& input = game.input; + // Readying up while choosing closes the menu, back to the countdown with the loadout's tooltips. + Match& match = game.match; + bool isReady = match.state == MATCH_COUNTDOWN && match.isReady[game.local]; + if (isReady && !wasReady && (match.isWeaponPick || match.isTrinketPick)) + isOpen = false; + wasReady = isReady; + // Escape works as the menu control too, except on a match's results, where it goes back to the sandbox (unless + // it's closing the menu over them); online, that's leaving the queued game. Watching one, Escape or the menu + // control goes back to the lobby instead. + Session& session = game.session; + bool isEscapePressed = input.is_pressed(SDL_SCANCODE_ESCAPE); + bool isEscape = isEscapePressed && (isOpen || match.state != MATCH_OVER); + isEscapeTaken = isEscape; + if (session.is_watching() && (isEscapePressed || input.is_control_pressed(CONTROL_MENU))) + { + session.watch_stop(game); + isOpen = false; + isEscapeTaken = true; + game.audio.play(SOUND_SWAP); + return; + } + if (isEscapePressed && !isOpen && match.state == MATCH_OVER && session.is_playing_arena(game)) + { + session.arena_leave(game); + isEscapeTaken = true; + return; + } + if (input.is_control_pressed(CONTROL_MENU) || isEscape) + { + isOpen = !isOpen; + game.audio.play(SOUND_SWAP); + if (isOpen) + { + game.local_get().charge_cancel(game); + targets_reset(game); + } + } + isForfeitArmed &= isOpen; + int tabs[TAB_COUNT]; + int shown = tabs_get(game, tabs); + if (std::find(tabs, tabs + shown, tab) == tabs + shown) + tab = tabs[0]; + sandbox_players_join(game); + if (!isOpen) + return; + + MenuLayout layout = layout_get(); + Vec2 mouse = input.mouse; + bool isLeft = input.is_mouse_pressed(MOUSE_LEFT); + bool isRight = input.is_mouse_pressed(MOUSE_RIGHT); + Rect strip = tab_area_get(game, layout.header); + if (isLeft && is_point_in_rect(mouse, strip)) + { + int clicked = tabs[std::min(shown - 1, (int)((mouse.x - strip.x) / strip.w * shown))]; + if (clicked != tab) + game.audio.play(SOUND_SELECT); + tab = clicked; + } + if (tab >= ITEM_TABS) + return; + bool isTrinketTab = tab == TAB_TRINKETS; + + int& selection = selections[isTrinketTab]; + int& scroll = scrolls[isTrinketTab]; + Fighter& fighter = game.local_get(); + int slots = isTrinketTab ? fighter.trinketSlots : fighter.weaponSlots; + bool isChoosable = is_choosable(game, isTrinketTab); + for (int i = 0; i < item_count() && isLeft; i++) + { + if (!is_point_in_rect(mouse, box_rect(i))) + continue; + bool isAgain = selection == i; + selection = i; + pinnedSlot = -1; + if (isAgain && isChoosable) + target_fill(game); + else + game.audio.play(SOUND_SELECT); + } + if (input.wheel != 0 && !is_point_in_rect(mouse, layout.description)) + scroll = std::clamp(scroll + (input.wheel > 0 ? -1 : 1), 0, std::max(0, row_count() - layout.rows)); + + // A gamepad's right stick scrolls the description (it's kept in range when drawn). + float stick = 0; + for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) + if (input.devices[i].pad && !input.devices[i].isClaimed) + stick += input.devices[i].axes[SDL_GAMEPAD_AXIS_RIGHTY]; + if (std::abs(stick) > STICK_DEADZONE) + description.scroll = std::max(0.0f, description.scroll + stick * STICK_SCROLL * TICK); + + if (input.slotPressed >= 0 && input.slotPressed < slots && isChoosable) + { + slot_assign(game, input.slotPressed); + pinnedSlot = -1; + } + for (int kind = 0; kind < ITEM_TABS; kind++) + { + bool isTrinket = kind == TAB_TRINKETS; + int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + for (int index = 0; index < count; index++) + { + if (!is_point_in_rect(mouse, slot_rect(isTrinket, index, count))) + continue; + // A slot this tab can't fill (the other kind's, or one not being chosen) is shown in the description. + int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[index]; + // One of this tab's own slots becomes the target, and shows what's in it. + if (isLeft && isTrinket == isTrinketTab && isChoosable) + { + targets[tab] = index; + pinnedSlot = item >= 0 ? index : -1; + pinnedTab = tab; + isPinnedTrinket = isTrinket; + game.audio.play(SOUND_SELECT); + } + else if (isLeft && item >= 0) + { + pinnedSlot = index; + pinnedTab = tab; + isPinnedTrinket = isTrinket; + game.audio.play(SOUND_SELECT); + } + if (isRight && kind == tab && isChoosable) + slot_clear(game, isTrinket, index); + } + } + + int step = + (int)input.is_key_control_pressed(CONTROL_LEVEL_UP) - (int)input.is_key_control_pressed(CONTROL_LEVEL_DOWN); + if (step != 0 && game.match.state == MATCH_PRACTICE) + level_step(game, step, mouse); +} + +// Levels one copy, the one in the hotbar slot under the mouse, or else the selected item along with every copy of it +// equipped (and any copies equipped later). +void Menu::level_step(Game& game, int step, Vec2 mouse) +{ + Fighter& fighter = game.local_get(); + for (int kind = 0; kind < ITEM_TABS; kind++) + { + bool isTrinket = kind == TAB_TRINKETS; + int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + for (int i = 0; i < count; i++) + if (loadout[i] >= 0 && is_point_in_rect(mouse, slot_rect(isTrinket, i, count))) + return slot_level_step(game, isTrinket, i, step); + } + bool isTrinket = tab == TAB_TRINKETS; + int item = selections[tab]; + int level = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item]; + int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); + if (next != level) + game.input.edit_send( + game.devices[game.local], + edit_encode({.type = EDIT_ITEM_LEVEL, .isTrinket = isTrinket, .index = item, .value = next})); +} + +// Asks for one copy's level to move by step, through this screen's controls. +void Menu::slot_level_step(Game& game, bool isTrinket, int slot, int step) +{ + Fighter& fighter = game.local_get(); + int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[slot]; + int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); + if (next != level) + game.input.edit_send( + game.devices[game.local], + edit_encode({.type = EDIT_SLOT_LEVEL, .isTrinket = isTrinket, .index = slot, .value = next})); +} + +// Dims the world, then draws the panel with its tabs (and the match's buttons) and the open tab. +void Menu::draw(Game& game) +{ + if (!isOpen) + return; + + Renderer& renderer = game.renderer; + Ui& ui = game.ui; + MenuLayout layout = layout_get(); + renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, DIM_ALPHA)); + ui.panel_draw(renderer, layout.panel, ui.tint); + renderer.rect_draw({layout.panel.x, layout.header.y + layout.header.h, layout.panel.w, DIVIDER}, ui.tint); + ui.widgets_begin(1, true); + ui.focus_begin(game); + pad_update(game); + if (!isOpen) + { + ui.focus_end(game); + return; + } + + // The body goes first so the focus lands in it rather than on the header's buttons. + if (tab < ITEM_TABS) + items_draw(game, layout); + else if (tab == TAB_PLAY) + play_draw(game, layout); + else if (tab == TAB_CUSTOMIZE) + game.settings.customize_draw(game, layout.body); + else + game.settings.draw(game, layout.body, pages[TAB_SETTINGS]); + header_draw(game, layout); + ui.focus_end(game); + hints_draw(game, layout); +} + +// The gamepad's menu buttons: the bumpers step through the tabs shown, B goes back from the rules or closes the menu, +// and on an item tab X and Y level the selection down and up in the sandbox. +void Menu::pad_update(Game& game) +{ + Input& input = game.input; + if (input.captureControl >= 0) + return; + int tabs[TAB_COUNT]; + int shown = tabs_get(game, tabs); + int step = (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER) - + (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER); + if (step != 0 && shown > 1) + { + int index = std::find(tabs, tabs + shown, tab) - tabs; + tab = tabs[(index + step + shown) % shown]; + game.audio.play(SOUND_SELECT); + } + if (input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_EAST)) + { + if (tab == TAB_PLAY && page == PAGE_RULES) + page = PAGE_MAIN; + else if (tab == TAB_PLAY && page == PAGE_QUEUE) + page = PAGE_ONLINE; + else + isOpen = false; + game.audio.play(isOpen ? SOUND_SELECT : SOUND_SWAP); + } + if (tab >= ITEM_TABS || game.match.state != MATCH_PRACTICE) + return; + int level = (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_NORTH) - + (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_WEST); + if (level != 0) + level_step(game, level, {-1, -1}); +} + +// While the focus shows, the gamepad buttons that work here, under the panel (or the hotbars below it). +void Menu::hints_draw(Game& game, MenuLayout& layout) +{ + Ui& ui = game.ui; + if (!ui.isFocusShown) + return; + const char* hint = "A: Select B: Close LB/RB: Tabs"; + if (tab < ITEM_TABS) + hint = game.match.state == MATCH_PRACTICE ? "A: Equip/Unequip X/Y: Level RS: Scroll Info LB/RB: Tabs" + : "A: Equip/Unequip RS: Scroll Info B: Close LB/RB: Tabs"; + else if (tab == TAB_PLAY && (page == PAGE_RULES || page == PAGE_QUEUE)) + hint = "A: Select Left/Right: Change B: Back Y: Join"; + else if (tab == TAB_SETTINGS) + hint = "A: Select Left/Right: Change X: Unbind B: Close"; + Vec2 size = game.text.size_get(hint); + float y = tab < ITEM_TABS ? BAR_Y + SLOT_SIZE + HINT_GAP : layout.panel.y + layout.panel.h + HINT_GAP; + game.text.draw(game.renderer, hint, {.color = ui.shade, .position = {std::round((CANVAS_WIDTH - size.x) / 2), y}}); +} + +// Equips the selection in the first empty slot (the last if they're full), or takes it off if it's already in one. +void Menu::item_toggle(Game& game) +{ + Fighter& fighter = game.local_get(); + bool isTrinket = tab == TAB_TRINKETS; + if (!is_choosable(game, isTrinket)) + return; + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int item = selections[tab]; + int equipped = std::find(loadout, loadout + slots, item) - loadout; + if (equipped < slots) + { + loadout_set(game, isTrinket, equipped, -1); + game.audio.play(SOUND_UNEQUIP); + return; + } + int empty = std::find(loadout, loadout + slots, -1) - loadout; + loadout_set(game, isTrinket, std::min(empty, slots - 1), item); + game.audio.play(SOUND_EQUIP); +} + +// The tab names spread across the strip (big when they fit), and in a match a Forfeit button that asks again before +// leaving, with a Ready button beside it while a round counts down. +void Menu::header_draw(Game& game, MenuLayout& layout) +{ + Renderer& renderer = game.renderer; + Text& text = game.text; + Ui& ui = game.ui; + Rect strip = tab_area_get(game, layout.header); + int tabs[TAB_COUNT]; + int shown = tabs_get(game, tabs); + float width = 0; + for (int i = 0; i < shown; i++) + width += text.size_get(TAB_NAMES[tabs[i]], FONT_SMALL, TAB_SCALE).x + PADDING * 2; + float scale = width <= strip.w ? TAB_SCALE : 1; + for (int i = 0; i < shown; i++) + { + // Another tab under the mouse gets a faint box behind its name. + Rect cell = {std::round(strip.x + strip.w * i / shown), strip.y, std::round(strip.w / shown), strip.h}; + bool isHovered = tabs[i] != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); + if (isHovered) + renderer.rect_draw({cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}, + color_fade(ui.shade, TAB_HOVER_ALPHA)); + const char* name = TAB_NAMES[tabs[i]]; + Vec2 size = text.size_get(name, FONT_SMALL, scale); + float center = strip.x + strip.w * (i * 2 + 1) / (shown * 2); + Vec2 corner = {std::round(center - size.x / 2), std::round(strip.y + (strip.h - size.y) / 2)}; + text.draw( + renderer, name, + {.color = tabs[i] == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); + if (i > 0) + renderer.rect_draw({std::round(strip.x + strip.w * i / shown - DIVIDER / 2), strip.y + PADDING, DIVIDER, + strip.h - PADDING * 2}, + ui.tint); + } + + Match& match = game.match; + if (match.state == MATCH_PRACTICE) + return; + Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING}; + if (match.state == MATCH_COUNTDOWN) + { + bool isReady = match.isReady[game.local]; + ui.widgets_enable(!isReady); + game.input.devices[game.devices[game.local]].isReady |= + ui.button_draw(game, button, isReady ? "Ready!" : "Ready", &match.isReady[game.local]); + ui.widgets_enable(true); + } + button.x += HEADER_BUTTON_WIDTH; + const char* forfeit = match.state == MATCH_OVER ? "Leave" : "Forfeit"; + if (!ui.button_draw(game, button, isForfeitArmed ? "Are you sure?" : forfeit, &isForfeitArmed)) + return; + if (isForfeitArmed && game.session.is_playing_arena(game)) + { + game.session.arena_leave(game); + isOpen = false; + } + else if (isForfeitArmed) + match.sandbox_return(game); + isForfeitArmed = !isForfeitArmed; +} + +// An item tab: the item grid with its scroll bar, the selection's description (scrolling, back to its top for each +// new selection), and the line between the two hotbars below. The focus moves the selection through the grid (scrolling +// it), and confirming equips or unequips it. +void Menu::items_draw(Game& game, MenuLayout& layout) +{ + Renderer& renderer = game.renderer; + Ui& ui = game.ui; + int& scroll = scrolls[tab]; + int scrolled = std::clamp(scroll + ui.scroll_take(layout.grid), 0, std::max(0, row_count() - layout.rows)); + if (scrolled != scroll) + ui.scroll_mark(); + scroll = scrolled; + int& selection = selections[tab]; + for (int i = 0; i < item_count(); i++) + { + Rect box = box_rect(i); + if (box.w <= 0) + continue; + if (ui.focus_add(&selection, i, box, false, i == selection)) + { + if (selection != i) + pinnedSlot = -1; + selection = i; + if (ui.isConfirmed) + item_toggle(game); + } + int* levels = tab == TAB_TRINKETS ? game.local_get().trinketItemLevels : game.local_get().weaponItemLevels; + ui.slot_draw(game, {.rect = box, + .item = i, + .level = levels[i], + .isHoverable = true, + .isLit = i == selection, + .isTrinket = tab == TAB_TRINKETS}); + } + + Rect track = layout.scroll; + int scrollMax = std::max(0, row_count() - layout.rows); + float headHeight = std::max(SCROLL_HEAD_MIN, track.h * std::min(1.0f, (float)layout.rows / row_count())); + float headY = track.y + (scrollMax > 0 ? (track.h - headHeight) * scrolls[tab] / scrollMax : 0); + renderer.rect_draw(track, ui.shade); + renderer.rect_draw({track.x, std::round(headY), track.w, std::round(headHeight)}, ui.tint); + + renderer.rect_draw(layout.divider, ui.tint); + // The description is of the selection, or of a hotbar slot clicked on this tab while it still holds something. + Fighter& fighter = game.local_get(); + bool isTrinket = tab == TAB_TRINKETS; + int item = selections[tab]; + int level = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item]; + int* slotLevel = nullptr; + if (pinnedTab != tab) + pinnedSlot = -1; + int pinnedSlots = isPinnedTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* pinnedLoadout = isPinnedTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + if (pinnedSlot >= 0 && pinnedSlot < pinnedSlots && pinnedLoadout[pinnedSlot] >= 0) + { + isTrinket = isPinnedTrinket; + item = pinnedLoadout[pinnedSlot]; + slotLevel = &(isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[pinnedSlot]; + level = *slotLevel; + } + int shown = isTrinket * WEAPON_COUNT * TRINKET_COUNT + item; + if (shown != described) + description.scroll = 0; + described = shown; + Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level); + Rect area = layout.description; + bool isLevelable = game.match.state == MATCH_PRACTICE; + if (isLevelable) + area.h -= WIDGET_HEIGHT + LEVEL_ROW_GAP; + ui.tooltip_page_draw(game, description, area, tooltip); + + // In the sandbox, a row under the description levels what it shows: the copy in a clicked hotbar slot, or else the + // selection with every copy of it equipped. A button greys out at the end of the levels. + if (!isLevelable) + return; + // The level between a minus and a plus, centred. + char text[TOOLTIP_LENGTH]; + SDL_snprintf(text, sizeof(text), "Level %d", level + 1); + Vec2 size = game.text.size_get(text); + float width = WIDGET_HEIGHT * 2 + LEVEL_ROW_GAP * 2 + size.x; + float x = std::round(area.x + (area.w - width) / 2); + float y = area.y + area.h + LEVEL_ROW_GAP; + for (int step : {-1, 1}) + { + Rect button = {step < 0 ? x : x + width - WIDGET_HEIGHT, y, WIDGET_HEIGHT, WIDGET_HEIGHT}; + ui.widgets_enable(level + step >= 0 && level + step < LEVEL_COUNT); + if (!ui.button_draw(game, button, step < 0 ? "-" : "+", step < 0 ? "level down" : "level up")) + continue; + if (slotLevel) + slot_level_step(game, isPinnedTrinket, pinnedSlot, step); + else + level_step(game, step, {-1, -1}); + } + ui.widgets_enable(true); + game.text.draw(renderer, text, + {.color = ui.tint, + .position = {x + WIDGET_HEIGHT + LEVEL_ROW_GAP, std::round(y + (WIDGET_HEIGHT - size.y) / 2)}}); +} + +// The Play tab: the trail of pages taken under the tabs, then Main's choice of single player or multiplayer, the rules +// for single player, or the Multiplayer page. +void Menu::play_draw(Game& game, MenuLayout& layout) +{ + Ui& ui = game.ui; + crumbs_draw(game, layout); + Rect area = layout.body; + area.y += CRUMB_HEIGHT + DIVIDER; + area.h -= CRUMB_HEIGHT + DIVIDER; + if (page == PAGE_RULES || page == PAGE_QUEUE) + { + bool isQueue = page == PAGE_QUEUE; + rules_draw(game, area, isQueue ? queueRules : game.match.rules, isQueue); + return; + } + if (page == PAGE_ONLINE) + { + online_draw(game, area); + return; + } + + constexpr const char* GAMES[] = {"Single Player", "Multiplayer"}; + for (int i = 0; i < (int)std::size(GAMES); i++) + { + if (ui.button_draw(game, {area.x, area.y + i * (PLAY_HEIGHT + PADDING), area.w, PLAY_HEIGHT}, GAMES[i])) + { + page = i == 0 ? PAGE_RULES : PAGE_ONLINE; + isMultiplayer = i == 1; + } + } +} + +// Puts text on the system clipboard. SDL keeps its own clipboard in the browser, so there it goes straight to the +// page's (which needs a secure page and a recent click, as a button's is), falling back to the old copy command where +// that's refused or missing. +static void clipboard_set(const std::string& text) +{ +#ifdef SDL_PLATFORM_EMSCRIPTEN + EM_ASM( + { + var text = UTF8ToString($0); + var fallback = function() + { + var area = document.createElement('textarea'); + area.value = text; + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try + { + document.execCommand('copy'); + } + catch (error) + {} + document.body.removeChild(area); + }; + if (navigator.clipboard && window.isSecureContext) + navigator.clipboard.writeText(text).catch(fallback); + else + fallback(); + }, + text.c_str()); +#else + SDL_SetClipboardText(text.c_str()); +#endif +} + +// The page a lobby link opens: this one, in the browser, or the game's own page from the desktop. +static std::string lobby_page_get() +{ +#ifdef SDL_PLATFORM_EMSCRIPTEN + char* page = (char*)EM_ASM_PTR({ return stringToNewUTF8(location.origin + location.pathname); }); + std::string url = page; + free(page); + return url; +#else + return LOBBY_PAGE_DEFAULT; +#endif +} + +// Online play. Outside a lobby: make one, or type a lobby's code to join it, with how the last attempt is going (or +// why it failed). In one: its code (to read out or copy to friends) and leaving, queueing a game in its sandbox (or +// watching the one being played), and everyone in it with how their direct connection is doing, this screen's player +// previewed on the right. +void Menu::online_draw(Game& game, Rect area) +{ + Ui& ui = game.ui; + Net& net = game.net; + Text& text = game.text; + float y = area.y; + auto status_draw = [&](const char* line, bool isLit) { + text.draw(game.renderer, line, {.color = isLit ? ui.tint : ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); + y += ROW_HEIGHT; + }; + + if (net.state != NET_LOBBY) + { + bool isBusy = net.state == NET_CONNECTING || net.state == NET_JOINING; + ui.widgets_enable(!isBusy); + if (ui.button_draw(game, {area.x, y, area.w, PLAY_HEIGHT}, "Create Lobby")) + net.lobby_create(game); + y += PLAY_HEIGHT + PADDING; + ui.label_draw(game, "Lobby Code", {area.x, y}); + Rect field = {area.x + LABEL_WIDTH, y, CODE_FIELD_WIDTH, WIDGET_HEIGHT}; + ui.field_draw(game, field, joinCode, sizeof(joinCode)); + for (char& c : joinCode) + c = (char)SDL_toupper(c); + ui.widgets_enable(!isBusy && SDL_strlen(joinCode) == LOBBY_CODE_LENGTH); + if (ui.button_draw(game, {field.x + field.w + PADDING, y, JOIN_BUTTON_WIDTH, WIDGET_HEIGHT}, "Join")) + net.lobby_join(game, joinCode); + ui.widgets_enable(true); + y += ROW_HEIGHT + PADDING; + if (net.state == NET_CONNECTING) + status_draw("Connecting to the server...", false); + else if (net.state == NET_JOINING) + status_draw("Joining...", false); + else if (!net.error.empty()) + status_draw(net.error.c_str(), true); + return; + } + + // The lobby's code, big, with buttons to copy it (or a link to it) and to leave under it. This screen's player is + // previewed in a column of its own on the right. + Rect whole = area; + area.w -= FIGHTER_PREVIEW_WIDTH + PADDING; + char line[TOOLTIP_LENGTH]; + SDL_snprintf(line, sizeof(line), "Lobby %s", net.code.c_str()); + text.draw(game.renderer, line, {.color = ui.tint, .position = {area.x, y}, .scale = {CODE_SCALE, CODE_SCALE}}); + y += text.line_height_get(FONT_SMALL, CODE_SCALE) + PADDING; + Uint64 now = SDL_GetTicks(); + float third = std::floor((area.w - PADDING * 2) / 3); + for (int i = 0; i < 2; i++) + { + // A copy button says so for a moment after it's used. + bool isCopied = copiedButton == i && now < copiedUntil; + Rect button = {area.x + (third + PADDING) * i, y, third, WIDGET_HEIGHT}; + if (ui.button_draw(game, button, + isCopied ? "Copied!" + : i == 0 ? "Copy Code" + : "Copy Link", + i == 0 ? "copy code" : "copy link")) + { + clipboard_set(i == 0 ? net.code : lobby_page_get() + "?lobby=" + net.code); + copiedButton = i; + copiedUntil = now + COPIED_SHOWN; + } + } + if (ui.button_draw(game, {area.x + (third + PADDING) * 2, y, third, WIDGET_HEIGHT}, "Leave")) + net.lobby_leave(); + y += ROW_HEIGHT + PADDING; + + // Queueing a game (or stopping), and watching the one being played. Someone playing one can do neither. + Session& session = game.session; + bool isPlaying = session.is_playing_arena(game); + bool isQueued = session.is_queued(game); + ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Queue"); + y += ROW_HEIGHT; + bool isArena = session.is_arena_on(); + int buttons = isArena ? 3 : 2; + float width = std::floor((area.w - PADDING * (buttons - 1)) / buttons); + ui.widgets_enable(session.isJoined && !isPlaying); + if (ui.button_draw(game, {area.x, y, width, WIDGET_HEIGHT}, "Queue a Game")) + page = PAGE_QUEUE; + ui.widgets_enable(session.isJoined && isQueued); + if (ui.button_draw(game, {area.x + width + PADDING, y, width, WIDGET_HEIGHT}, "Stop Queueing")) + session.unqueue_send(game); + ui.widgets_enable(!isPlaying); + if (isArena && ui.button_draw(game, {area.x + (width + PADDING) * 2, y, width, WIDGET_HEIGHT}, "Spectate")) + { + session.watch_start(game); + isOpen = false; + } + ui.widgets_enable(true); + y += ROW_HEIGHT; + if (isArena) + { + std::string playing = "Playing: " + session.arena_names_get(game); + text.draw(game.renderer, playing.c_str(), {.color = ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); + y += ROW_HEIGHT; + } + + // Everyone in it, in the order they came (the first hosting), in their colours, with their connections. + ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Players"); + y += ROW_HEIGHT; + for (NetMember& member : net.members) + { + bool isSelf = member.id == net.selfId; + std::string name = session.isActive ? session.name_get(game, member.id) : member.name; + SDL_snprintf(line, sizeof(line), "- %s%s%s", name.c_str(), member.id == net.hostId ? " [Host]" : "", + isSelf ? " [You]" : ""); + int fighter = session.fighter_get(game, member.id); + Color color = fighter >= 0 && game.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint; + text.draw(game.renderer, line, {.color = color, .position = {area.x, y + TEXT_ROW_INSET}}); + float x = area.x + text.size_get(line).x + PADDING * 2; + if (!isSelf && !member.isConnected) + text.draw(game.renderer, "Connecting...", {.color = ui.shade, .position = {x, y + TEXT_ROW_INSET}}); + else if (!isSelf) + ui.ping_draw(game, {x, y + TEXT_ROW_INSET}, member.ping, 1); + y += ROW_HEIGHT; + } + if (game.session.isDesynced) + status_draw("Out of sync with the lobby: rejoin to fix it", true); + else if (game.session.isActive && !game.session.isJoined) + status_draw("Joining the lobby's game...", false); + + // This screen's player, as everyone sees them, on the right. + Fighter& own = game.local_get(); + ui.fighter_preview_draw(game, whole, ui.name_get(game, own.index), game.color_get(own.index, true), ""); +} + +// The pages taken to get here, left to right, the current one lit. Clicking an earlier one goes back to it. +void Menu::crumbs_draw(Game& game, MenuLayout& layout) +{ + Renderer& renderer = game.renderer; + Text& text = game.text; + Ui& ui = game.ui; + Rect bar = {layout.panel.x, layout.header.y + layout.header.h + DIVIDER, layout.panel.w, CRUMB_HEIGHT}; + const char* crumbs[] = {"Main", isMultiplayer ? "Multiplayer" : "Single Player", "Queue a Game"}; + constexpr int CRUMB_PAGES[] = {PAGE_MAIN, PAGE_ONLINE, PAGE_QUEUE}; + int count = page == PAGE_QUEUE ? 3 : page == PAGE_MAIN ? 1 : 2; + float x = bar.x + PADDING; + float y = std::round(bar.y + (bar.h - LINE_HEIGHT) / 2); + for (int i = 0; i < count; i++) + { + bool isCurrent = i == count - 1; + Vec2 size = text.size_get(crumbs[i]); + bool isHovered = !isCurrent && is_point_in_rect(game.input.mouse, {x, bar.y, size.x, bar.h}); + if (isHovered && game.input.isClicked) + { + page = CRUMB_PAGES[i]; + game.audio.play(SOUND_SELECT); + } + text.draw(renderer, crumbs[i], {.color = isCurrent || isHovered ? ui.tint : ui.shade, .position = {x, y}}); + x += size.x + CRUMB_GAP; + if (isCurrent) + break; + text.draw(renderer, ">", {.color = ui.shade, .position = {x, y}}); + x += text.size_get(">").x + CRUMB_GAP; + } + renderer.rect_draw({bar.x, bar.y + bar.h, bar.w, DIVIDER}, ui.tint); +} + +// The game about to be played: what it makes for (its summary) at the top, then its rules under a Rules header that +// folds them away (remembered in the settings), scrolling, with the button that starts it pinned along the bottom: Play +// for single player, or in a lobby's Queue a Game, Host (which queues it in the lobby's sandbox for others to join). +void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) +{ + Ui& ui = game.ui; + Page& rulesPage = pages[TAB_PLAY]; + float x = area.x + LABEL_WIDTH; + float y = 0; + Rect footer = {area.x, area.y + area.h - PLAY_HEIGHT, area.w, PLAY_HEIGHT}; + area.h -= PLAY_HEIGHT + PADDING; + ui.page_begin(game, rulesPage, area); + Rect row = {area.x, 0, area.w - SCROLL_WIDTH * 2, WIDGET_HEIGHT}; + auto isRow = [&](float height) { + bool isShown = ui.row_get(rulesPage, height, y); + row.y = y; + return isShown; + }; + auto radios = [&](const char* label, const char* const* names, int count, int& value) { + if (!isRow(ROW_HEIGHT)) + return; + ui.label_draw(game, label, {area.x, y}); + float radioX = x; + for (int i = 0; i < count; i++) + { + ui.radio_draw(game, {radioX, y}, names[i], value, i); + radioX += WIDGET_HEIGHT + WIDGET_GAP + game.text.size_get(names[i]).x + RADIO_GAP; + } + }; + auto number = [&](const char* label, auto& value, NumberRange range) { + if (!isRow(ROW_HEIGHT)) + return; + ui.label_draw(game, label, {area.x, y}); + ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, value, range); + }; + auto toggle = [&](const char* label, bool& value) { + if (isRow(ROW_HEIGHT)) + ui.toggle_draw(game, {x, y}, label, value); + }; + auto separator = [&](const char* label) { + if (isRow(ROW_HEIGHT)) + ui.separator_draw(game, row, label); + }; + auto items = [&](const char* title, ItemRules& items) { + separator(title); + number("Slots", items.slots, {.min = 1, .max = SLOT_MAX}); + int choice = items.isChoice; + radios("Picked By", PICK_NAMES, std::size(PICK_NAMES), choice); + items.isChoice = choice; + toggle("Mirror (Everyone Gets the Same)", items.isMirror); + toggle("Duplicates Allowed", items.isDuplicates); + radios("Picked On", TIMING_NAMES, std::size(TIMING_NAMES), items.timing); + if (items.timing == PICK_PERIOD) + number("Period", items.period, {.min = PERIOD_MIN, .max = PERIOD_MAX, .suffix = " Rounds"}); + number("Start Level", items.startLevel, {.min = 1, .max = LEVEL_COUNT}); + toggle("Level Up Each Round", items.isLevelUp); + }; + + if (isRow(ui.game_summary_size(game, rules).y + PADDING)) + ui.game_summary_draw(game, rules, {area.x, y}); + Settings& settings = game.settings; + if (isRow(ROW_HEIGHT) && ui.fold_draw(game, row, "Rules", settings.isRulesOpen)) + { + settings.isRulesOpen = !settings.isRulesOpen; + settings.changed(); + } + if (!settings.isRulesOpen) + { + ui.page_end(game, rulesPage); + play_button_draw(game, footer, isQueue); + return; + } + + // Races are set aside while deathmatch gets online; bring the mode choice back with them. + // radios("Mode", MODE_NAMES, std::size(MODE_NAMES), rules.mode); + if (isRow(ROW_HEIGHT) && ui.button_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, "Default")) + rules = rules_get(rules.mode); + separator("Players"); + number("Count", rules.players, {.min = PLAYERS_MIN, .max = PLAYERS_MAX}); + if (!isQueue) + players_join(game); + for (int i = 0; i < rules.players && !isQueue; i++) + { + if (!isRow(ROW_HEIGHT)) + continue; + char label[LABEL_LENGTH]; + char player[LABEL_LENGTH]; + int device = game.devices[i]; + SDL_snprintf(label, sizeof(label), "Player %d", i + 1); + if (device == DEVICE_KEYBOARD) + SDL_strlcpy(player, "Keyboard & Mouse", sizeof(player)); + else if (device != DEVICE_NONE) + SDL_snprintf(player, sizeof(player), "Gamepad %d (Back Leaves)", device); + else + SDL_strlcpy(player, "Bot (Y on a Gamepad Joins)", sizeof(player)); + ui.label_draw(game, label, {area.x, y}); + game.text.draw(game.renderer, player, + {.color = device == DEVICE_NONE ? ui.shade : ui.tint, .position = {x, y + TEXT_ROW_INSET}}); + } + ui.widgets_enable(isQueue); + toggle("Fill Empty Slots with Bots", rules.isBotFill); + ui.widgets_enable(true); + items("Weapons", rules.weapons); + items("Trinkets", rules.trinkets); + separator("Rounds"); + // A deathmatch is won by taking more than half its rounds, so only odd counts make sense. + if (rules.mode != MODE_RACE) + number("Best Of", rules.bestOf, {.min = 1, .max = BEST_OF_MAX, .step = 2}); + rules.bestOf = std::clamp(rules.bestOf, 1, BEST_OF_MAX) | 1; + number("Timer", rules.timer, {.min = 0, .max = TIMER_MAX, .zero = "Infinite", .suffix = " Min"}); + number("Base Health", rules.health, {.min = BASE_HEALTH_MIN, .max = BASE_HEALTH_MAX, .step = BASE_HEALTH_STEP}); + number("Round Health Multiplier", rules.healthMultiplier, + {.min = 1, .max = MULTIPLIER_MAX, .step = MULTIPLIER_STEP, .suffix = "x"}); + ui.page_end(game, rulesPage); + play_button_draw(game, footer, isQueue); +} + +// The rules page's Play: starts the single player game, which can't happen from inside a lobby (where it's Host on a +// queued game's page instead, which queues it). +void Menu::play_button_draw(Game& game, Rect button, bool isQueue) +{ + Ui& ui = game.ui; + if (isQueue) + { + if (ui.button_draw(game, button, "Host")) + { + game.session.queue_send(game, queueRules); + page = PAGE_ONLINE; + isOpen = false; + } + return; + } + ui.widgets_enable(!game.session.isActive); + if (ui.button_draw(game, button, "Play")) + { + game.input.isPlayRequested = true; + isOpen = false; + } + ui.widgets_enable(true); +} + +// In the sandbox, Back on a spare gamepad brings in another fighter for it, and Back on a joined one takes it out. +void Menu::sandbox_players_join(Game& game) +{ + Input& input = game.input; + if (game.match.state != MATCH_PRACTICE || game.session.isActive) + return; + // This runs in the tick, so it reads the presses since the last tick (taps), not since the last frame. + auto is_back = [&](int device) { + return (input.devices[device].taps >> SDL_GAMEPAD_BUTTON_BACK & 1) != 0; + }; + for (int i = game.fighterCount - 1; i > 0; i--) + if (game.devices[i] > DEVICE_KEYBOARD && is_back(game.devices[i])) + game.match.sandbox_leave(game, i); + for (int device = DEVICE_KEYBOARD + 1; device < DEVICE_COUNT; device++) + if (input.devices[device].pad && !input.devices[device].isClaimed && is_back(device)) + game.match.sandbox_join(game, device); +} + +// Gamepads join the game from the rules: Y on an unclaimed one takes the first bot's slot, Back on a joined one hands +// its slot back to a bot. Slots past the player count are given up. +void Menu::players_join(Game& game) +{ + Input& input = game.input; + int players = game.match.rules.players; + for (int i = 0; i < MAX_FIGHTERS; i++) + { + int device = game.devices[i]; + if (device <= DEVICE_KEYBOARD || (i < players && !input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_BACK))) + continue; + input.devices[device].isClaimed = false; + game.devices[i] = DEVICE_NONE; + game.audio.play(SOUND_UNEQUIP); + } + for (int device = DEVICE_KEYBOARD + 1; device < DEVICE_COUNT; device++) + { + Device& pad = input.devices[device]; + if (!pad.pad || pad.isClaimed || !(pad.presses >> SDL_GAMEPAD_BUTTON_NORTH & 1)) + continue; + int* slot = std::find(game.devices + 1, game.devices + players, DEVICE_NONE); + if (slot == game.devices + players) + continue; + *slot = device; + pad.isClaimed = true; + game.audio.play(SOUND_EQUIP); + } +} + +// Opens the menu on the Multiplayer page, as joining a lobby from a link does. +void Menu::online_open() +{ + isOpen = true; + tab = TAB_PLAY; + page = PAGE_ONLINE; + isMultiplayer = true; +} + +// Opens the menu on the item tab a round's countdown lets fighters choose from. +void Menu::choice_open(Game& game) +{ + isOpen = true; + tab = game.match.isWeaponPick ? TAB_WEAPONS : TAB_TRINKETS; + targets_reset(game); +} + +// Points each item tab's target at its first empty slot, or the first slot when they're all full. +void Menu::targets_reset(Game& game) +{ + Fighter& fighter = game.local_get(); + for (int kind = 0; kind < ITEM_TABS; kind++) + { + bool isTrinket = kind == TAB_TRINKETS; + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int empty = std::find(loadout, loadout + slots, -1) - loadout; + targets[kind] = empty < slots ? empty : 0; + } +} + +// The target slot of a kind while its tab is open and can be filled, or -1. +int Menu::target_get(Game& game, bool isTrinket) +{ + Fighter& fighter = game.local_get(); + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + if (!is_bars_open() || (tab == TAB_TRINKETS) != isTrinket || !is_choosable(game, isTrinket) || slots == 0) + return -1; + return std::min(targets[tab], slots - 1); +} + +// Puts the selection in the target slot (or clears it, if it's already there), then moves the target on to the next +// empty slot after it, if there is one. +void Menu::target_fill(Game& game) +{ + Fighter& fighter = game.local_get(); + bool isTrinket = tab == TAB_TRINKETS; + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int target = target_get(game, isTrinket); + if (target < 0) + return; + bool isClearing = loadout[target] == selections[tab]; + slot_assign(game, target); + for (int step = 1; step < slots && !isClearing; step++) + { + int next = (target + step) % slots; + if (loadout[next] >= 0) + continue; + targets[tab] = next; + break; + } +} + +bool Menu::is_bars_open() +{ + return isOpen && tab < ITEM_TABS; +} + +// Fills tabs with the ones shown, in order, returning how many: every tab in the sandbox, both item tabs during a +// countdown that lets fighters choose either kind, and only Settings anywhere else. +int Menu::tabs_get(Game& game, int* tabs) +{ + Match& match = game.match; + int count = 0; + if (match.state == MATCH_PRACTICE) + { + for (int i = 0; i < TAB_COUNT; i++) + tabs[count++] = i; + return count; + } + if (match.isWeaponPick || match.isTrinketPick) + { + tabs[count++] = TAB_WEAPONS; + tabs[count++] = TAB_TRINKETS; + } + if (count == 0) + tabs[count++] = TAB_SETTINGS; + return count; +} + +// Whether this screen can fill its slots of a kind from the menu: always in the sandbox, and in a match only while its +// countdown lets fighters choose that kind (the other kind's tab is there to look at). +bool Menu::is_choosable(Game& game, bool isTrinket) +{ + Match& match = game.match; + if (match.state == MATCH_PRACTICE) + return true; + return match.state == MATCH_COUNTDOWN && (isTrinket ? match.isTrinketPick : match.isWeaponPick); +} + +// Where a hotbar slot sits: closed, the weapon bar is top left and the trinket bar bottom left. On an item tab, +// both sit under the menu either side of its middle. +Rect Menu::slot_rect(bool isTrinket, int index, int count) +{ + float step = SLOT_SIZE + SLOT_GAP; + Vec2 position = {HOTBAR_MARGIN + index * step, + isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE : HOTBAR_MARGIN}; + if (is_bars_open()) + position = {isTrinket ? CANVAS_WIDTH / 2.0f + SLOT_GAP + index * step + : CANVAS_WIDTH / 2.0f - SLOT_GAP - (count - index) * step + SLOT_GAP, + BAR_Y}; + return {std::round(position.x), std::round(position.y), SLOT_SIZE, SLOT_SIZE}; +} + +// Lays the panel out: the header across the top, and below it, right to left, the description, divider, scroll bar +// and as many grid columns as fit. +MenuLayout Menu::layout_get() +{ + MenuLayout layout; + Rect panel = {(CANVAS_WIDTH - MENU_WIDTH) / 2, MENU_TOP, MENU_WIDTH, HEADER_HEIGHT + BODY_HEIGHT}; + float bodyTop = panel.y + HEADER_HEIGHT + DIVIDER + PADDING; + float bodyHeight = panel.y + panel.h - PADDING - bodyTop; + + layout.panel = panel; + layout.header = {panel.x, panel.y, panel.w, HEADER_HEIGHT}; + layout.body = {panel.x + PADDING, bodyTop, panel.w - PADDING * 2, bodyHeight}; + layout.description = {panel.x + panel.w - PADDING - DESCRIPTION_WIDTH, bodyTop, DESCRIPTION_WIDTH, bodyHeight}; + layout.divider = {layout.description.x - PADDING - DIVIDER, bodyTop, DIVIDER, bodyHeight}; + layout.scroll = {layout.divider.x - PADDING - SCROLL_WIDTH, bodyTop, SCROLL_WIDTH, bodyHeight}; + layout.grid = {panel.x + PADDING, bodyTop, layout.scroll.x - PADDING - panel.x - PADDING, bodyHeight}; + layout.columns = std::max(1, (int)((layout.grid.w + SLOT_GAP) / (SLOT_SIZE + SLOT_GAP))); + layout.rows = std::max(1, (int)((layout.grid.h + SLOT_GAP) / (SLOT_SIZE + SLOT_GAP))); + return layout; +} + +// A grid box for the item, or an empty rect if it's scrolled out of view. +Rect Menu::box_rect(int index) +{ + MenuLayout layout = layout_get(); + int row = index / layout.columns - scrolls[tab]; + int column = index % layout.columns; + if (row < 0 || row >= layout.rows) + return {}; + + float step = SLOT_SIZE + SLOT_GAP; + return {layout.grid.x + column * step, layout.grid.y + row * step, SLOT_SIZE, SLOT_SIZE}; +} + +int Menu::item_count() +{ + return tab == TAB_TRINKETS ? TRINKET_COUNT : WEAPON_COUNT; +} + +int Menu::row_count() +{ + int columns = layout_get().columns; + return (item_count() + columns - 1) / columns; +} + +// Puts the selected item in a slot, or empties it if it's already there. +void Menu::slot_assign(Game& game, int slot) +{ + Fighter& fighter = game.local_get(); + int* loadout = tab == TAB_TRINKETS ? fighter.trinketLoadout : fighter.weaponLoadout; + int item = selections[tab]; + bool isClearing = loadout[slot] == item; + loadout_set(game, tab == TAB_TRINKETS, slot, isClearing ? -1 : item); + game.audio.play(isClearing ? SOUND_UNEQUIP : SOUND_EQUIP); +} + +void Menu::slot_clear(Game& game, bool isTrinket, int slot) +{ + Fighter& fighter = game.local_get(); + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + if (loadout[slot] < 0) + return; + loadout_set(game, isTrinket, slot, -1); + game.audio.play(SOUND_UNEQUIP); +} + +// Sets one of this screen's loadout slots, through its controls (see Edit). +void Menu::loadout_set(Game& game, bool isTrinket, int slot, int item) +{ + game.input.edit_send(game.devices[game.local], pick_encode(isTrinket, slot, item)); +} diff --git a/src/menu.h b/src/menu.h new file mode 100644 index 0000000..ad87f02 --- /dev/null +++ b/src/menu.h @@ -0,0 +1,126 @@ +#pragma once + +#include "common.h" +#include "protocol.h" +#include "ui.h" + +struct Game; + +// The menu's frame: a panel (header then body) centred across the top, with the item tabs' hotbars a row under it and +// the gamepad hints under those. +constexpr float MENU_TOP = 24; +constexpr float MENU_WIDTH = 440; +constexpr float HEADER_HEIGHT = 28; +constexpr float BODY_HEIGHT = 240; +constexpr float BAR_SPACING = 12; +constexpr float BAR_Y = MENU_TOP + HEADER_HEIGHT + BODY_HEIGHT + BAR_SPACING; +constexpr float HINT_GAP = 3; + +enum MenuTab +{ + TAB_WEAPONS, + TAB_TRINKETS, + TAB_PLAY, + TAB_CUSTOMIZE, + TAB_SETTINGS, + TAB_COUNT +}; + +enum PlayPage +{ + PAGE_MAIN, + PAGE_RULES, + PAGE_ONLINE, + // A lobby's Queue a Game: the rules again, with Host for Play. + PAGE_QUEUE +}; + +constexpr int ITEM_TABS = TAB_PLAY; + +// Where everything in the open menu sits. Rows is how many grid rows fit on screen. +struct MenuLayout +{ + Rect body; + Rect description; + Rect divider; + Rect grid; + Rect header; + Rect panel; + Rect scroll; + int columns = 1; + int rows = 1; +}; + +// The Tab menu. In the sandbox: browse every weapon or trinket, read about it, level it and assign it to hotbar slots, +// set up and start a game from Play, change your name in Customize, and Settings. In a match: Settings and a Forfeit +// button (pressed twice), or only the item tabs being chosen from while a round's countdown lets fighters choose +// them, with a Ready button. On an item tab the hotbars sit under the menu. Play asks the match to start on its next +// tick (through the input). On a gamepad (or the arrow keys and Enter) the focus moves between widgets, the bumpers +// switch tabs and B goes back a page or closes the menu. +class Menu +{ +public: + Page pages[TAB_COUNT]; + int tab = TAB_WEAPONS; + bool isOpen = false; + // Whether this tick's Escape went to the menu, so nothing else takes it too. + bool isEscapeTaken = false; + + void update(Game& game); + void draw(Game& game); + void choice_open(Game& game); + void online_open(); + void targets_reset(Game& game); + void target_fill(Game& game); + bool is_bars_open(); + Rect slot_rect(bool isTrinket, int index, int count); + bool is_choosable(Game& game, bool isTrinket); + int target_get(Game& game, bool isTrinket); + +private: + Page description; + int described = -1; + // A hotbar slot clicked to read about (its item shown instead of the selection) while still on the tab it was + // clicked on, or -1. + int pinnedSlot = -1; + // Each item tab's target slot: where an item clicked a second time goes. + int targets[ITEM_TABS] = {}; + int pinnedTab = -1; + bool isPinnedTrinket = false; + bool wasReady = false; + int scrolls[ITEM_TABS] = {}; + int selections[ITEM_TABS] = {}; + int page = PAGE_MAIN; + bool isForfeitArmed = false; + bool isMultiplayer = false; + // The rules a game queued in a lobby is hosted with (kept apart from this screen's own single player ones). + Rules queueRules = rules_get(MODE_DEATHMATCH); + // The lobby code typed on the Multiplayer page. + char joinCode[LOBBY_CODE_LENGTH + 1] = {}; + // Which lobby copy button (code or link) was last used, and until when it says so. + int copiedButton = -1; + Uint64 copiedUntil = 0; + + MenuLayout layout_get(); + Rect box_rect(int index); + int tabs_get(Game& game, int* tabs); + int item_count(); + int row_count(); + void slot_assign(Game& game, int slot); + void slot_clear(Game& game, bool isTrinket, int slot); + void loadout_set(Game& game, bool isTrinket, int slot, int item); + void item_toggle(Game& game); + void level_step(Game& game, int step, Vec2 mouse); + void slot_level_step(Game& game, bool isTrinket, int slot, int step); + void pad_update(Game& game); + void hints_draw(Game& game, MenuLayout& layout); + void header_draw(Game& game, MenuLayout& layout); + void items_draw(Game& game, MenuLayout& layout); + void play_draw(Game& game, MenuLayout& layout); + void rules_draw(Game& game, Rect area, Rules& rules, bool isQueue); + void online_draw(Game& game, Rect area); + void play_button_draw(Game& game, Rect button, bool isQueue); + void players_join(Game& game); + void sandbox_players_join(Game& game); + void crumbs_draw(Game& game, MenuLayout& layout); +}; diff --git a/src/net.cpp b/src/net.cpp new file mode 100644 index 0000000..f413cc0 --- /dev/null +++ b/src/net.cpp @@ -0,0 +1,493 @@ +#include "net.h" +#include "game.h" +#include +#include +#include +#include +#include +#include +#ifdef SDL_PLATFORM_EMSCRIPTEN +#include +#endif + +constexpr const char STUN_SERVER[] = "stun:stun.l.google.com:19302"; +constexpr const char CHANNEL_LABEL[] = "game"; +constexpr const char INPUTS_LABEL[] = "inputs"; +constexpr Uint64 PING_INTERVAL = 1000; +constexpr Uint64 HEARTBEAT_INTERVAL = 20000; +constexpr float NS_PER_MS = 1e6f; + +// A direct connection to one other member: the connection and, once they're there, its two data channels: a reliable, +// ordered one for everything that mustn't go missing, and one for inputs, which are sent again in every packet until +// they're acknowledged, so it neither waits on nor resends a lost one. +struct PeerLink +{ + std::shared_ptr connection; + std::shared_ptr channel; + std::shared_ptr inputs; + + bool is_open() const + { + return channel && channel->isOpen() && inputs && inputs->isOpen(); + } +}; + +struct Net::Impl +{ + std::shared_ptr socket; + std::map links; + std::string pending; + simdjson::dom::parser parser; + Uint64 nextPing = 0; + // The network simulator (SAB_NET_LAG milliseconds on everything sent, SAB_NET_LOSS percent of input packets + // dropped), for trying rollback on one machine: sends wait here until they're due. + struct Delayed + { + Uint64 due; + int to; + bool isReliable; + std::string bytes; + bool isText = false; + }; + std::vector delayed; + Uint64 lag = 0; + int loss = 0; + Uint32 lossSeed = 1; + Uint64 nextHeartbeat = 0; + bool isHeartbeating = false; + + // What the network threads report, waiting for the main thread. + std::mutex lock; + std::vector> posted; + + // On desktop, the network calls back on its own threads, so its news waits for the main thread's next update. The + // browser calls back on the main thread already, and keeps doing so in a hidden tab, where frames (and so updates) + // stop; there it's handled at once, so a hidden tab still answers a connection being made to it. + void post(std::function work) + { +#ifdef SDL_PLATFORM_EMSCRIPTEN + work(); +#else + std::lock_guard guard(lock); + posted.push_back(std::move(work)); +#endif + } +}; + +Net::Net() : impl(std::make_unique()) +{ + if (const char* lag = SDL_getenv("SAB_NET_LAG")) + impl->lag = SDL_atoi(lag); + if (const char* loss = SDL_getenv("SAB_NET_LOSS")) + impl->loss = SDL_atoi(loss); +#ifndef SDL_PLATFORM_EMSCRIPTEN + rtc::InitLogger(rtc::LogLevel::Warning); +#endif +} + +// Pings the server so proxies between (which close connections that go quiet) keep it open. The server answers, +// so it's busy both ways. +void Net::heartbeat() +{ + if (impl->socket && impl->socket->isOpen()) + impl->socket->send(std::string("{\"type\":\"ping\"}")); +} + +Net::~Net() +{ + shutdown(); +} + +// Closes every connection, waiting for nothing; anything still queued is dropped. +void Net::shutdown() +{ + for (auto& [id, link] : impl->links) + if (link.connection) + link.connection->close(); + impl->links.clear(); + if (impl->socket) + impl->socket->close(); + impl->socket.reset(); + std::lock_guard guard(impl->lock); + impl->posted.clear(); +} + +void Net::lobby_create(Game& game) +{ + request_send(game, "{\"type\":\"create\",\"name\":" + json_quote(game.settings.name) + + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); +} + +void Net::lobby_join(Game& game, const char* lobby) +{ + request_send(game, "{\"type\":\"join\",\"code\":" + json_quote(lobby) + ",\"name\":" + + json_quote(game.settings.name) + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); +} + +// Leaves the lobby, closing every direct connection but keeping the one to the server. +void Net::lobby_leave() +{ + for (auto& [id, link] : impl->links) + link.connection->close(); + impl->links.clear(); + if (impl->socket && impl->socket->isOpen() && state == NET_LOBBY) + impl->socket->send(std::string("{\"type\":\"leave\"}")); + members.clear(); + packets.clear(); + kicks.clear(); + code.clear(); + selfId = hostId = -1; + state = NET_OFFLINE; +} + +// Sends a create or join, connecting to the server first if need be (it goes out once the socket opens). The +// server is the one in Settings (Host), or SAB_SERVER_URL when set (for testing against a local one). +void Net::request_send(Game& game, const std::string& request) +{ + error.clear(); + if (impl->socket && impl->socket->isOpen()) + { + impl->socket->send(request); + state = NET_JOINING; + return; + } + impl->pending = request; + state = NET_CONNECTING; + if (impl->socket) + return; + impl->socket = std::make_shared(); +#ifdef SDL_PLATFORM_EMSCRIPTEN + // A browser timer keeps the server connection alive even while the tab is hidden and frames stop. + if (!impl->isHeartbeating) + emscripten_set_interval([](void* net) { static_cast(net)->heartbeat(); }, HEARTBEAT_INTERVAL, this); + impl->isHeartbeating = true; +#endif + Impl* shared = impl.get(); + impl->socket->onOpen([this, shared]() { + shared->post([this]() { + if (impl->pending.empty()) + return; + impl->socket->send(impl->pending); + impl->pending.clear(); + state = NET_JOINING; + }); + }); + impl->socket->onMessage([this, shared](rtc::message_variant data) { + if (std::holds_alternative(data)) + shared->post([this, text = std::get(data)]() { message_handle(text); }); + }); + impl->socket->onClosed([this, shared]() { + shared->post([this]() { + lobby_leave(); + impl->socket.reset(); + impl->pending.clear(); + if (error.empty()) + error = "Lost the connection to the server"; + }); + }); + impl->socket->onError([this, shared](std::string message) { + shared->post([this, message]() { error = "Couldn't reach the server (" + message + ")"; }); + }); + const char* url = SDL_getenv("SAB_SERVER_URL"); + impl->socket->open(url ? url : game.settings.serverUrl); +} + +NetMember* Net::member_get(int id) +{ + for (NetMember& member : members) + if (member.id == id) + return &member; + return nullptr; +} + +// One message from the server. +void Net::message_handle(const std::string& text) +{ + simdjson::dom::element message; + std::string_view type; + if (impl->parser.parse(text).get(message) || message["type"].get(type)) + return; + int64_t number = 0; + std::string_view field; + + if (type == "error") + { + error = message["message"].get(field) ? "Server error" : std::string(field); + if (state != NET_LOBBY) + state = NET_OFFLINE; + return; + } + if (type == "welcome") + { + members.clear(); + selfId = message["id"].get(number) ? -1 : (int)number; + hostId = message["host"].get(number) ? -1 : (int)number; + code = message["code"].get(field) ? "" : std::string(field); + simdjson::dom::array list; + if (!message["members"].get(list)) + for (simdjson::dom::element entry : list) + { + NetMember member; + member.id = entry["id"].get(number) ? -1 : (int)number; + member.name = entry["name"].get(field) ? "" : std::string(field); + member.isConnected = member.id == selfId; + members.push_back(member); + } + state = NET_LOBBY; + // The newcomer reaches out to everyone already here. + for (NetMember& member : members) + if (member.id != selfId) + peer_open(member.id, true); + return; + } + if (type == "member_join") + { + NetMember member; + member.id = message["id"].get(number) ? -1 : (int)number; + member.name = message["name"].get(field) ? "" : std::string(field); + members.push_back(member); + return; + } + if (type == "kicked") + { + // Kicked out by the host: out of the lobby, saying why, here and in the chat. + std::string reason = text_clean(message["reason"].get(field) ? "" : field, KICK_REASON_MAX); + std::string text = "You were kicked by the host." + (reason.empty() ? "" : " Reason: " + reason); + state = NET_OFFLINE; + lobby_leave(); + error = text; + notices.push_back(text); + return; + } + if (type == "member_leave") + { + int id = message["id"].get(number) ? -1 : (int)number; + bool isKicked = false; + if (!message["kicked"].get(isKicked) && isKicked) + kicks[id] = text_clean(message["reason"].get(field) ? "" : field, KICK_REASON_MAX); + hostId = message["host"].get(number) ? hostId : (int)number; + peer_close(id); + std::erase_if(members, [&](NetMember& member) { return member.id == id; }); + return; + } + if (type == "signal") + { + simdjson::dom::element payload; + if (!message["from"].get(number) && !message["payload"].get(payload)) + signal_handle((int)number, simdjson::minify(payload)); + } +} + +// Starts a direct connection to another member. The one offering makes the data channel, which starts the +// negotiation; the other gets it once the offer is answered. Either way, descriptions and candidates go through the +// server. +void Net::peer_open(int id, bool isOfferer) +{ + rtc::Configuration config; + config.iceServers.emplace_back(STUN_SERVER); + PeerLink& link = impl->links[id]; + link.connection = std::make_shared(config); + Impl* shared = impl.get(); + auto signal_send = [this](int to, const std::string& payload) { + if (impl->socket && impl->socket->isOpen()) + impl->socket->send("{\"type\":\"signal\",\"to\":" + std::to_string(to) + ",\"payload\":" + payload + "}"); + }; + link.connection->onLocalDescription([shared, signal_send, id](rtc::Description description) { + std::string payload = "{\"sdp\":" + json_quote(std::string(description)) + + ",\"kind\":" + json_quote(description.typeString()) + "}"; + shared->post([signal_send, id, payload]() { signal_send(id, payload); }); + }); + link.connection->onLocalCandidate([shared, signal_send, id](rtc::Candidate candidate) { + std::string payload = + "{\"candidate\":" + json_quote(candidate.candidate()) + ",\"mid\":" + json_quote(candidate.mid()) + "}"; + shared->post([signal_send, id, payload]() { signal_send(id, payload); }); + }); + + // A member is connected once both its channels are open. The reliable one also answers pings and times pongs. + // Binary messages on either are queued for the session. + auto channel_watch = [this, shared, id](std::shared_ptr channel) { + auto watched = impl->links.find(id); + if (watched == impl->links.end()) + { + channel->close(); + return; + } + bool isReliable = channel->label() != INPUTS_LABEL; + (isReliable ? watched->second.channel : watched->second.inputs) = channel; + channel->onOpen([this, shared, id]() { + shared->post([this, id]() { + auto link = impl->links.find(id); + NetMember* member = member_get(id); + if (member && link != impl->links.end()) + member->isConnected = link->second.is_open(); + }); + }); + channel->onClosed([this, shared, id]() { + shared->post([this, id]() { + if (NetMember* member = member_get(id)) + member->isConnected = false; + }); + }); + channel->onMessage([this, shared, id, isReliable](rtc::message_variant data) { + if (std::holds_alternative(data)) + { + rtc::binary& bytes = std::get(data); + std::string packet(reinterpret_cast(bytes.data()), bytes.size()); + shared->post([this, id, isReliable, packet]() { + packets.push_back({.from = id, .isReliable = isReliable, .bytes = packet}); + }); + return; + } + shared->post([this, id, text = std::get(data)]() { + auto link = impl->links.find(id); + if (link == impl->links.end() || !link->second.channel || !link->second.channel->isOpen()) + return; + if (text.starts_with("ping:")) + text_send(id, "pong:" + text.substr(5)); + else if (text.starts_with("pong:")) + if (NetMember* member = member_get(id)) + member->ping = (SDL_GetTicksNS() - SDL_strtoull(text.c_str() + 5, nullptr, 10)) / NS_PER_MS; + }); + }); + }; + if (isOfferer) + { + rtc::DataChannelInit unreliable; + unreliable.reliability.unordered = true; + unreliable.reliability.maxRetransmits = 0; + channel_watch(link.connection->createDataChannel(CHANNEL_LABEL)); + channel_watch(link.connection->createDataChannel(INPUTS_LABEL, unreliable)); + } + else + link.connection->onDataChannel([shared, channel_watch](std::shared_ptr channel) { + shared->post([channel_watch, channel]() { channel_watch(channel); }); + }); +} + +void Net::peer_close(int id) +{ + auto link = impl->links.find(id); + if (link == impl->links.end()) + return; + link->second.connection->close(); + impl->links.erase(link); +} + +// A description or candidate from another member. An offer from someone not yet connected starts the connection on +// this side. +void Net::signal_handle(int from, const std::string& text) +{ + simdjson::dom::element payload; + std::string_view sdp; + std::string_view kind; + std::string_view candidate; + std::string_view mid; + if (impl->parser.parse(text).get(payload) || !member_get(from)) + return; + if (!payload["sdp"].get(sdp) && !payload["kind"].get(kind)) + { + if (!impl->links.contains(from)) + peer_open(from, false); + impl->links[from].connection->setRemoteDescription(rtc::Description(std::string(sdp), std::string(kind))); + return; + } + if (!payload["candidate"].get(candidate) && !payload["mid"].get(mid) && impl->links.contains(from)) + impl->links[from].connection->addRemoteCandidate(rtc::Candidate(std::string(candidate), std::string(mid))); +} + +// Sends a binary message to another member, if they're connected. +void Net::send(int to, const std::string& bytes, bool isReliable) +{ + if (impl->lag > 0 || impl->loss > 0) + { + impl->lossSeed = impl->lossSeed * 1103515245 + 12345; + if (!isReliable && (int)(impl->lossSeed >> 16) % 100 < impl->loss) + return; + if (impl->lag > 0) + { + impl->delayed.push_back({SDL_GetTicks() + impl->lag, to, isReliable, bytes}); + return; + } + } + send_now(to, bytes, isReliable); +} + +void Net::send_now(int to, const std::string& bytes, bool isReliable) +{ + auto link = impl->links.find(to); + if (link == impl->links.end() || !link->second.is_open()) + return; + auto& channel = isReliable ? link->second.channel : link->second.inputs; + channel->send(reinterpret_cast(bytes.data()), bytes.size()); +} + +// A text message (pings and pongs) on the reliable channel, held back by the network simulator like everything else. +void Net::text_send(int to, const std::string& text) +{ + if (impl->lag > 0) + impl->delayed.push_back({SDL_GetTicks() + impl->lag, to, true, text, true}); + else + text_send_now(to, text); +} + +void Net::text_send_now(int to, const std::string& text) +{ + auto link = impl->links.find(to); + if (link != impl->links.end() && link->second.channel && link->second.channel->isOpen()) + link->second.channel->send(text); +} + +// Asks the server to put a member out of the lobby (it only does so for the host). +void Net::kick_send(int member, const std::string& reason) +{ + if (impl->socket && impl->socket->isOpen() && state == NET_LOBBY) + impl->socket->send("{\"type\":\"kick\",\"id\":" + std::to_string(member) + ",\"reason\":" + json_quote(reason) + + "}"); +} + +// Whether a direct connection to every other member is open. +bool Net::is_connected_all() +{ + for (NetMember& member : members) + if (!member.isConnected) + return false; + return state == NET_LOBBY; +} + +// Once a frame: handles what the network reported (on desktop), pings every connected member, and on desktop keeps the +// server connection alive. +void Net::update() +{ + std::vector> work; + { + std::lock_guard guard(impl->lock); + work.swap(impl->posted); + } + for (auto& job : work) + job(); + + Uint64 now = SDL_GetTicks(); + while (!impl->delayed.empty() && impl->delayed.front().due <= now) + { + Impl::Delayed due = impl->delayed.front(); + impl->delayed.erase(impl->delayed.begin()); + if (due.isText) + text_send_now(due.to, due.bytes); + else + send_now(due.to, due.bytes, due.isReliable); + } + if (state == NET_LOBBY && now >= impl->nextPing) + { + impl->nextPing = now + PING_INTERVAL; + for (auto& [id, link] : impl->links) + if (link.channel && link.channel->isOpen()) + text_send(id, "ping:" + std::to_string(SDL_GetTicksNS())); + } +#ifndef SDL_PLATFORM_EMSCRIPTEN + if (now >= impl->nextHeartbeat) + { + impl->nextHeartbeat = now + HEARTBEAT_INTERVAL; + heartbeat(); + } +#endif +} diff --git a/src/net.h b/src/net.h new file mode 100644 index 0000000..1b85fbe --- /dev/null +++ b/src/net.h @@ -0,0 +1,84 @@ +#pragma once + +#include "protocol.h" +#include +#include +#include + +struct Game; + +enum NetState +{ + NET_OFFLINE, + NET_CONNECTING, + NET_JOINING, + NET_LOBBY +}; + +// A binary message from another member, on the reliable channel (in order, never lost) or the inputs one (either may +// fail). +struct NetPacket +{ + int from = -1; + bool isReliable = true; + std::string bytes; +}; + +// Someone in the lobby (this screen's player too): their server id and name, whether a direct connection to them is +// open, and the round trip over it in milliseconds (-1 until measured). This screen's own member is always connected. +struct NetMember +{ + int id = -1; + std::string name; + bool isConnected = false; + float ping = -1; +}; + +// Online play: a WebSocket to the server (see protocol.h) to make or join a lobby and pass signaling, and a +// WebRTC data channel to each other member, connected directly. A newcomer connects out to everyone already there. +// The network calls back on its own threads (on desktop), so everything it reports is queued and handled in update, +// once a frame on the main thread. Members are in the order they joined, so the first hosts. Error holds why the last +// request failed or the connection was lost, until the next request. +class Net +{ +public: + Net(); + ~Net(); + + void lobby_create(Game& game); + void lobby_join(Game& game, const char* code); + void lobby_leave(); + void update(); + void shutdown(); + void heartbeat(); + void send(int to, const std::string& bytes, bool isReliable); + void kick_send(int member, const std::string& reason); + bool is_connected_all(); + + NetState state = NET_OFFLINE; + std::string code; + std::string error; + int selfId = -1; + int hostId = -1; + std::vector members; + // Binary messages from other members, oldest first, for the session to take. + std::vector packets; + // Lines for the chat log from the server (like this screen being kicked), oldest first, for the chat to take. + std::vector notices; + // Members the server said were kicked, with the reason (empty for none given). + std::map kicks; + +private: + struct Impl; + std::unique_ptr impl; + + void request_send(Game& game, const std::string& request); + void send_now(int to, const std::string& bytes, bool isReliable); + void text_send(int to, const std::string& text); + void text_send_now(int to, const std::string& text); + void message_handle(const std::string& text); + void peer_open(int id, bool isOfferer); + void peer_close(int id); + void signal_handle(int from, const std::string& payload); + NetMember* member_get(int id); +}; diff --git a/src/protocol.h b/src/protocol.h new file mode 100644 index 0000000..042cc56 --- /dev/null +++ b/src/protocol.h @@ -0,0 +1,81 @@ +#pragma once + +// What the game and the server agree on. The server hands out lobby codes, keeps each lobby's members and passes +// WebRTC signaling between them; matches themselves run peer to peer. Messages are JSON objects with a "type": +// +// Game to server: +// create {name, version} make a lobby and join it as host +// join {code, name, version} join a lobby by its code +// leave {} leave the lobby +// kick {id, reason} (the host) put someone out of the lobby +// signal {to, payload} pass a WebRTC description or candidate to another member +// ping {} keeps the connection alive through proxies (answered with pong) +// +// Server to game: +// welcome {id, code, host, members: [{id, name}]} in a lobby (members includes this one) +// member_join {id, name} +// member_leave {id, host, kicked?, reason?} host is who hosts now; kicked says they were kicked +// kicked {reason} this member was kicked out of the lobby +// signal {from, payload} +// error {message} the request failed; the connection stays open +// pong {} +// +// A signal's payload is either a description {sdp, kind} or a candidate {candidate, mid}. + +#include +#include + +constexpr int PROTOCOL_VERSION = 2; +constexpr int LOBBY_MEMBERS_MAX = 4; +constexpr int LOBBY_CODE_LENGTH = 4; +// Letters only, without I and O, so codes are easy to read out and type. +constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ"; +constexpr int PLAYER_NAME_MAX = 24; +constexpr int CHAT_MESSAGE_MAX = 100; +constexpr int KICK_REASON_MAX = 60; +constexpr const char SERVER_URL_DEFAULT[] = "wss://shweetz.net/smack-a-brother/ws"; +constexpr int SERVER_URL_LENGTH = 128; + +// Text from elsewhere (another player, or the server) made safe to show: only printable ASCII (what the game's font +// has; anything else, emoji included, is dropped), cut to max characters, with spaces at its ends trimmed. +inline std::string text_clean(std::string_view raw, size_t max) +{ + std::string text; + for (char c : raw) + if (c >= ' ' && c <= '~' && text.size() < max) + text += c; + size_t first = text.find_first_not_of(' '); + size_t last = text.find_last_not_of(' '); + return first == std::string::npos ? "" : text.substr(first, last - first + 1); +} + +// A count of seconds in words: "1 second", "29 seconds". +inline std::string seconds_say(int seconds) +{ + return std::to_string(seconds) + (seconds == 1 ? " second" : " seconds"); +} + +// A string as a JSON string literal, quotes included. +inline std::string json_quote(const std::string& text) +{ + std::string quoted = "\""; + for (unsigned char c : text) + if (c == '"' || c == '\\') + quoted += '\\', quoted += (char)c; + else if (c == '\n') + quoted += "\\n"; + else if (c == '\r') + quoted += "\\r"; + else if (c == '\t') + quoted += "\\t"; + else if (c < 0x20) + { + constexpr char HEX[] = "0123456789abcdef"; + quoted += "\\u00"; + quoted += HEX[c >> 4]; + quoted += HEX[c & 15]; + } + else + quoted += (char)c; + return quoted + "\""; +} diff --git a/src/renderer.cpp b/src/renderer.cpp new file mode 100644 index 0000000..e5931a9 --- /dev/null +++ b/src/renderer.cpp @@ -0,0 +1,456 @@ +#include "renderer.h" +#include +#include +#include + +#define GL_GLEXT_PROTOTYPES +#include + +constexpr int ATLAS_PADDING = 2; +constexpr int ATLAS_SIZE = 1024; +constexpr int LOG_LENGTH = 1024; +constexpr int MAX_VERTICES = 6 * 16384; +constexpr int QUAD_CORNERS = 4; +constexpr int WHITE_SIZE = 4; +constexpr int QUAD_ORDER[] = {0, 1, 2, 0, 2, 3}; +constexpr float COLOR_MAX = 255.0f; +constexpr Color BACKGROUND = BLACK; +constexpr Color LETTERBOX = BLACK; + +// Web and mobile get OpenGL ES 3.0 (WebGL2), desktops 3.3 core. Both run the same shader sources under their own +// version line. +#if defined(SDL_PLATFORM_EMSCRIPTEN) || defined(SDL_PLATFORM_ANDROID) || defined(SDL_PLATFORM_IOS) +constexpr int GL_MAJOR = 3; +constexpr int GL_MINOR = 0; +constexpr int GL_PROFILE = SDL_GL_CONTEXT_PROFILE_ES; +constexpr const char* SHADER_VERSION = "#version 300 es\nprecision highp float;\n"; +#else +constexpr int GL_MAJOR = 3; +constexpr int GL_MINOR = 3; +constexpr int GL_PROFILE = SDL_GL_CONTEXT_PROFILE_CORE; +constexpr const char* SHADER_VERSION = "#version 330 core\n"; +#endif + +// Places each vertex on the canvas in pixels, y down. +constexpr const char* SPRITE_VERT = R"( +layout(location = 0) in vec2 inPosition; +layout(location = 1) in vec2 inUv; +layout(location = 2) in vec4 inColor; +layout(location = 3) in float inFlash; + +uniform vec2 canvasSize; + +out vec2 uv; +out vec4 color; +out float flash; + +void main() +{ + vec2 ndc = inPosition / canvasSize * 2.0 - 1.0; + gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0); + uv = inUv; + color = inColor; + flash = inFlash; +} +)"; + +// Tints the atlas texel by the vertex colour, then mixes toward the plain colour by flash. +constexpr const char* SPRITE_FRAG = R"( +in vec2 uv; +in vec4 color; +in float flash; + +uniform sampler2D atlas; + +out vec4 outColor; + +void main() +{ + vec4 texel = texture(atlas, uv); + outColor = vec4(mix(texel.rgb * color.rgb, color.rgb, flash), texel.a * color.a); +} +)"; + +// Every GL entry point the renderer uses, looked up from the driver once a context exists, as SDL loads GL itself. +#define GL_FUNCTIONS(X) \ + X(AttachShader) \ + X(BindBuffer) \ + X(BindFramebuffer) \ + X(BindRenderbuffer) \ + X(BindTexture) \ + X(BindVertexArray) \ + X(BlendFuncSeparate) \ + X(BlitFramebuffer) \ + X(BufferData) \ + X(Clear) \ + X(ClearColor) \ + X(CompileShader) \ + X(CreateProgram) \ + X(CreateShader) \ + X(DeleteShader) \ + X(DrawArrays) \ + X(Enable) \ + X(EnableVertexAttribArray) \ + X(FramebufferRenderbuffer) \ + X(FramebufferTexture2D) \ + X(GenBuffers) \ + X(GenFramebuffers) \ + X(GenRenderbuffers) \ + X(GenTextures) \ + X(GenVertexArrays) \ + X(GetProgramInfoLog) \ + X(GetProgramiv) \ + X(GetShaderInfoLog) \ + X(GetShaderiv) \ + X(GetUniformLocation) \ + X(LinkProgram) \ + X(RenderbufferStorage) \ + X(ShaderSource) \ + X(TexImage2D) \ + X(TexParameteri) \ + X(Uniform2f) \ + X(UseProgram) \ + X(VertexAttribPointer) \ + X(Viewport) + +struct Gl +{ +#define GL_FUNCTION_DECLARE(name) decltype(&gl##name) name = nullptr; + GL_FUNCTIONS(GL_FUNCTION_DECLARE) +#undef GL_FUNCTION_DECLARE +}; + +static Gl gl; + +static bool gl_load() +{ + bool isLoaded = true; +#define GL_FUNCTION_LOAD(name) \ + gl.name = (decltype(gl.name))SDL_GL_GetProcAddress("gl" #name); \ + isLoaded &= gl.name != nullptr; + GL_FUNCTIONS(GL_FUNCTION_LOAD) +#undef GL_FUNCTION_LOAD + return isLoaded; +} + +static uint32_t color_pack(Color color) +{ + auto channel = [](float value) { + return (uint32_t)(std::clamp(value, 0.0f, 1.0f) * COLOR_MAX); + }; + return channel(color.r) | channel(color.g) << 8 | channel(color.b) << 16 | channel(color.a) << 24; +} + +// Compiles one stage under the platform's version line, logging why if it fails. +static GLuint shader_compile(GLenum stage, const char* source) +{ + GLuint shader = gl.CreateShader(stage); + const char* sources[] = {SHADER_VERSION, source}; + gl.ShaderSource(shader, std::size(sources), sources, nullptr); + gl.CompileShader(shader); + GLint isCompiled = 0; + gl.GetShaderiv(shader, GL_COMPILE_STATUS, &isCompiled); + if (!isCompiled) + { + char log[LOG_LENGTH]; + gl.GetShaderInfoLog(shader, sizeof(log), nullptr, log); + SDL_Log("Shader failed to compile: %s", log); + } + return shader; +} + +// The sprite program, its atlas on texture unit 0 and its canvas size set, or 0 if it failed to link. +static GLuint program_build() +{ + GLuint program = gl.CreateProgram(); + GLuint vertex = shader_compile(GL_VERTEX_SHADER, SPRITE_VERT); + GLuint fragment = shader_compile(GL_FRAGMENT_SHADER, SPRITE_FRAG); + gl.AttachShader(program, vertex); + gl.AttachShader(program, fragment); + gl.LinkProgram(program); + gl.DeleteShader(vertex); + gl.DeleteShader(fragment); + + GLint isLinked = 0; + gl.GetProgramiv(program, GL_LINK_STATUS, &isLinked); + if (!isLinked) + { + char log[LOG_LENGTH]; + gl.GetProgramInfoLog(program, sizeof(log), nullptr, log); + SDL_Log("Shader failed to link: %s", log); + return 0; + } + gl.UseProgram(program); + gl.Uniform2f(gl.GetUniformLocation(program, "canvasSize"), CANVAS_WIDTH, CANVAS_HEIGHT); + return program; +} + +// A nearest-filtered texture of the given size, bound, holding pixels (or nothing yet). +static GLuint texture_create(int width, int height, void* pixels) +{ + GLuint texture = 0; + gl.GenTextures(1, &texture); + gl.BindTexture(GL_TEXTURE_2D, texture); + gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl.TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + return texture; +} + +// Presents in step with the display, or as soon as each frame is ready. +void Renderer::vsync_set(bool isVsync) +{ + SDL_GL_SetSwapInterval(isVsync ? 1 : 0); +} + +// Makes the context and sets up all the state drawing needs, which never changes after: the program, the vertex +// layout and buffer, blending, the canvas framebuffer and the upscaled one (sized on the first present). +bool Renderer::init(SDL_Window* target) +{ + window = target; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, GL_MAJOR); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, GL_MINOR); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, GL_PROFILE); + context = SDL_GL_CreateContext(window); + if (!context || !gl_load() || !program_build()) + return false; + vsync_set(true); + + GLuint vertexArray = 0; + GLuint vertexBuffer = 0; + gl.GenVertexArrays(1, &vertexArray); + gl.BindVertexArray(vertexArray); + gl.GenBuffers(1, &vertexBuffer); + gl.BindBuffer(GL_ARRAY_BUFFER, vertexBuffer); + gl.VertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, x)); + gl.VertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, u)); + gl.VertexAttribPointer(2, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(Vertex), (void*)offsetof(Vertex, color)); + gl.VertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, flash)); + for (GLuint i = 0; i < QUAD_CORNERS; i++) + gl.EnableVertexAttribArray(i); + gl.Enable(GL_BLEND); + gl.BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + vertices.reserve(MAX_VERTICES); + + canvas = texture_create(CANVAS_WIDTH, CANVAS_HEIGHT, nullptr); + gl.GenFramebuffers(1, &canvasFramebuffer); + gl.BindFramebuffer(GL_FRAMEBUFFER, canvasFramebuffer); + gl.FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, canvas, 0); + gl.Viewport(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + gl.GenRenderbuffers(1, &upscaled); + gl.GenFramebuffers(1, &upscaledFramebuffer); + + atlasPixels.resize(ATLAS_SIZE * ATLAS_SIZE); + uint32_t whitePixels[WHITE_SIZE * WHITE_SIZE]; + std::fill(std::begin(whitePixels), std::end(whitePixels), UINT32_MAX); + Rect whiteBlock = atlas_add(whitePixels, WHITE_SIZE, WHITE_SIZE); + white = {whiteBlock.x + 1, whiteBlock.y + 1, WHITE_SIZE - 2, WHITE_SIZE - 2}; + return true; +} + +// Shelf-packs pixels into the CPU-side atlas and returns their region. +Rect Renderer::atlas_add(uint32_t* pixels, int width, int height) +{ + if (shelfX + width > ATLAS_SIZE) + { + shelfX = 0; + shelfY += shelfHeight + ATLAS_PADDING; + shelfHeight = 0; + } + if (shelfY + height > ATLAS_SIZE) + { + SDL_Log("Atlas is full"); + return {}; + } + + for (int y = 0; y < height; y++) + std::memcpy(&atlasPixels[(shelfY + y) * ATLAS_SIZE + shelfX], &pixels[y * width], width * sizeof(uint32_t)); + + Rect region = {(float)shelfX, (float)shelfY, (float)width, (float)height}; + shelfX += width + ATLAS_PADDING; + shelfHeight = std::max(shelfHeight, height); + return region; +} + +// Sends the packed atlas to the GPU, where it stays bound, and frees the CPU copy. +void Renderer::atlas_upload() +{ + atlas = texture_create(ATLAS_SIZE, ATLAS_SIZE, atlasPixels.data()); + atlasPixels = {}; +} + +// Packs an image from the resources folder into the atlas and returns where it landed, or an empty rect without a +// running renderer (headless) or when the file is missing. +Rect Renderer::image_load(const std::string& relative) +{ + if (atlasPixels.empty()) + return {}; + std::string path = path_get(relative.c_str()); + SDL_Surface* loaded = SDL_LoadPNG(path.c_str()); + if (!loaded) + { + SDL_Log("Failed to load %s: %s", path.c_str(), SDL_GetError()); + return {}; + } + + SDL_Surface* converted = SDL_ConvertSurface(loaded, SDL_PIXELFORMAT_RGBA32); + Rect image = atlas_add((uint32_t*)converted->pixels, converted->w, converted->h); + SDL_DestroySurface(converted); + SDL_DestroySurface(loaded); + return image; +} + +void Renderer::draw(Draw draw) +{ + quad_draw(draw.region, affine_get(draw.position, draw.angle, draw.scale, draw.origin), draw.color, draw.flash); +} + +// Draws an atlas region with its top-left corner at the transform's origin. +void Renderer::quad_draw(Rect region, Affine transform, Color tint, float flash) +{ + if (vertices.size() + std::size(QUAD_ORDER) > MAX_VERTICES) + flush(); + + float u0 = region.x / ATLAS_SIZE; + float v0 = region.y / ATLAS_SIZE; + float u1 = (region.x + region.w) / ATLAS_SIZE; + float v1 = (region.y + region.h) / ATLAS_SIZE; + uint32_t color = color_pack(tint); + + Vec2 corners[QUAD_CORNERS] = {{0, 0}, {region.w, 0}, {region.w, region.h}, {0, region.h}}; + Vec2 uvs[QUAD_CORNERS] = {{u0, v0}, {u1, v0}, {u1, v1}, {u0, v1}}; + Vertex quad[QUAD_CORNERS]; + for (int i = 0; i < QUAD_CORNERS; i++) + { + Vec2 corner = transform * corners[i] - camera; + quad[i] = {corner.x, corner.y, uvs[i].x, uvs[i].y, color, flash}; + } + + for (int index : QUAD_ORDER) + vertices.push_back(quad[index]); +} + +void Renderer::rect_draw(Rect rect, Color color) +{ + draw( + {.color = color, .region = white, .position = {rect.x, rect.y}, .scale = {rect.w / white.w, rect.h / white.h}}); +} + +void Renderer::line_draw(Vec2 from, Vec2 to, float width, Color color) +{ + Vec2 delta = to - from; + draw({.color = color, + .region = white, + .origin = {0, white.h / 2}, + .position = from, + .scale = {vec2_length(delta) / white.w, width / white.h}, + .angle = vec2_angle(delta)}); +} + +// Fills a circle from one-pixel rows so its edge stays crisp at any size. +void Renderer::circle_draw(Vec2 center, float radius, Color color) +{ + int rows = std::ceil(radius); + Vec2 middle = {std::round(center.x), std::round(center.y)}; + + for (int y = -rows; y < rows; y++) + { + float row = y + 0.5f; + if (std::abs(row) >= radius) + continue; + float half = std::round(std::sqrt(radius * radius - row * row)); + rect_draw({middle.x - half, middle.y + y, half * 2, 1}, color); + } +} + +// Outlines a circle one pixel thick, row by row like circle_draw, so it stays crisp and never overlaps itself. Each +// row spans from its own edge in to where its neighbouring rows reach. +void Renderer::ring_draw(Vec2 center, float radius, Color color) +{ + int rows = std::ceil(radius); + Vec2 middle = {std::round(center.x), std::round(center.y)}; + auto half_get = [&](int y) { + float row = y + 0.5f; + return std::abs(row) >= radius ? 0 : std::round(std::sqrt(radius * radius - row * row)); + }; + + for (int y = -rows; y < rows; y++) + { + float half = half_get(y); + if (half <= 0) + continue; + float width = std::max(1.0f, half - std::min(half_get(y - 1), half_get(y + 1))); + rect_draw({middle.x - half, middle.y + y, width, 1}, color); + rect_draw({middle.x + half - width, middle.y + y, width, 1}, color); + } +} + +// Fits the canvas inside a window, using whole-number scales once the window is at least canvas sized. +// The largest box with the canvas's shape that fits the window, centred. +Rect Renderer::letterbox_get(float width, float height) +{ + float scale = std::min(width / CANVAS_WIDTH, height / CANVAS_HEIGHT); + + float boxWidth = CANVAS_WIDTH * scale; + float boxHeight = CANVAS_HEIGHT * scale; + return {std::floor((width - boxWidth) / 2), std::floor((height - boxHeight) / 2), boxWidth, boxHeight}; +} + +// Resizes the upscaled buffer to factor times the canvas size. +void Renderer::upscaled_resize(int factor) +{ + if (factor == upscale) + return; + gl.BindRenderbuffer(GL_RENDERBUFFER, upscaled); + gl.RenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, CANVAS_WIDTH * factor, CANVAS_HEIGHT * factor); + gl.BindFramebuffer(GL_FRAMEBUFFER, upscaledFramebuffer); + gl.FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, upscaled); + upscale = factor; +} + +// Draws the batch so far onto the canvas (clearing it first, the first time in a frame) and empties it. +void Renderer::flush() +{ + gl.BindFramebuffer(GL_FRAMEBUFFER, canvasFramebuffer); + if (!isCanvasCleared) + { + gl.ClearColor(BACKGROUND.r, BACKGROUND.g, BACKGROUND.b, BACKGROUND.a); + gl.Clear(GL_COLOR_BUFFER_BIT); + isCanvasCleared = true; + } + gl.BufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices.data(), GL_STREAM_DRAW); + gl.DrawArrays(GL_TRIANGLES, 0, vertices.size()); + vertices.clear(); +} + +// Draws the rest of the frame onto the canvas, blits it up to the upscaled buffer, then into the letterboxed window +// (whose rows count from the bottom) and swaps. +void Renderer::present() +{ + flush(); + isCanvasCleared = false; + + int width = 0; + int height = 0; + SDL_GetWindowSizeInPixels(window, &width, &height); + Rect box = letterbox_get(width, height); + upscaled_resize(std::max(1, (int)std::ceil(box.w / CANVAS_WIDTH))); + int upscaledWidth = CANVAS_WIDTH * upscale; + int upscaledHeight = CANVAS_HEIGHT * upscale; + + gl.BindFramebuffer(GL_READ_FRAMEBUFFER, canvasFramebuffer); + gl.BindFramebuffer(GL_DRAW_FRAMEBUFFER, upscaledFramebuffer); + gl.BlitFramebuffer(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT, 0, 0, upscaledWidth, upscaledHeight, GL_COLOR_BUFFER_BIT, + GL_NEAREST); + + int bottom = height - box.y - box.h; + gl.BindFramebuffer(GL_READ_FRAMEBUFFER, upscaledFramebuffer); + gl.BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + gl.ClearColor(LETTERBOX.r, LETTERBOX.g, LETTERBOX.b, LETTERBOX.a); + gl.Clear(GL_COLOR_BUFFER_BIT); + gl.BlitFramebuffer(0, 0, upscaledWidth, upscaledHeight, box.x, bottom, box.x + box.w, bottom + box.h, + GL_COLOR_BUFFER_BIT, GL_LINEAR); + SDL_GL_SwapWindow(window); +} diff --git a/src/renderer.h b/src/renderer.h new file mode 100644 index 0000000..06d5bb7 --- /dev/null +++ b/src/renderer.h @@ -0,0 +1,75 @@ +#pragma once + +#include "common.h" +#include +#include +#include + +struct SDL_GLContextState; +struct SDL_Window; + +// One textured quad request. Region is in atlas pixels, origin is the pivot inside the region. +struct Draw +{ + Color color = WHITE; + Rect region; + Vec2 origin; + Vec2 position; + Vec2 scale = {1, 1}; + float angle = 0; + float flash = 0; +}; + +struct Vertex +{ + float x; + float y; + float u; + float v; + uint32_t color; + float flash; +}; + +// Batches every sprite from a single atlas onto a fixed canvas with OpenGL (3.3 core on desktop, ES 3.0 for web and +// mobile), in one draw call unless a frame fills the batch (then each full batch is drawn as it fills, so nothing is +// ever dropped), then scales it to fill the window: nearest up to the next whole multiple (the upscaled buffer), +// then smoothly down to fit, so pixels stay crisp at any size. GL objects are kept by their GL names. +class Renderer +{ +public: + Rect white; + Vec2 camera; + + bool init(SDL_Window* target); + Rect atlas_add(uint32_t* pixels, int width, int height); + void atlas_upload(); + Rect image_load(const std::string& relative); + void draw(Draw draw); + void quad_draw(Rect region, Affine transform, Color tint, float flash); + void rect_draw(Rect rect, Color color); + void line_draw(Vec2 from, Vec2 to, float width, Color color); + void circle_draw(Vec2 center, float radius, Color color); + void ring_draw(Vec2 center, float radius, Color color); + void present(); + void vsync_set(bool isVsync); + Rect letterbox_get(float width, float height); + +private: + std::vector atlasPixels; + std::vector vertices; + SDL_GLContextState* context = nullptr; + SDL_Window* window = nullptr; + unsigned int atlas = 0; + unsigned int canvas = 0; + unsigned int canvasFramebuffer = 0; + unsigned int upscaled = 0; + unsigned int upscaledFramebuffer = 0; + int shelfHeight = 0; + int shelfX = 0; + int shelfY = 0; + int upscale = 0; + bool isCanvasCleared = false; + + void flush(); + void upscaled_resize(int factor); +}; diff --git a/src/rollback.cpp b/src/rollback.cpp new file mode 100644 index 0000000..ade32d6 --- /dev/null +++ b/src/rollback.cpp @@ -0,0 +1,123 @@ +#include "rollback.h" +#include "game.h" +#include +#include +#include + +constexpr Uint32 FNV_OFFSET = 2166136261u; +constexpr Uint32 FNV_PRIME = 16777619u; + +void Rollback::init() +{ + isTesting = SDL_getenv("RPG_ROLLBACK_TEST"); +} + +void Rollback::update(Game& game) +{ + if (!isTesting) + { + game_tick(game); + return; + } + + int slot = tick % ROLLBACK_DEPTH; + snapshots[slot] = snapshot_save(game); + inputs[slot] = game.input; + for (int i = 0; i < game.fighterCount; i++) + controls[slot][i] = game.fighters[i].controls; + game_tick(game); + tick++; + if (tick < ROLLBACK_DEPTH) + return; + + Snapshot live = snapshot_save(game); + Input liveInput = game.input; + Uint32 expected = checksum_get(game); + + int oldest = tick % ROLLBACK_DEPTH; + snapshot_load(game, snapshots[oldest]); + game.audio.isMuted = true; + game.effects.isMuted = true; + for (int i = 0; i < ROLLBACK_DEPTH; i++) + { + int replayed = (oldest + i) % ROLLBACK_DEPTH; + game.input = inputs[replayed]; + for (int f = 0; f < game.fighterCount; f++) + game.fighters[f].controls = controls[replayed][f]; + game_tick(game); + } + game.audio.isMuted = false; + game.effects.isMuted = false; + + Uint32 result = checksum_get(game); + if (result != expected) + SDL_Log("Rollback desync at tick %d: live %08x, replayed %08x", tick, expected, result); + snapshot_load(game, live); + game.input = liveInput; +} + +Snapshot Rollback::snapshot_save(Game& game) +{ + Snapshot snapshot = {.match = game.match, + .weapons = game.weapons, + .world = game.world, + .rng = game.rng, + .fighterCount = game.fighterCount}; + std::copy(std::begin(game.fighters), std::end(game.fighters), snapshot.fighters); + std::copy(std::begin(game.bot.states), std::end(game.bot.states), snapshot.bots); + return snapshot; +} + +void Rollback::snapshot_load(Game& game, Snapshot& snapshot) +{ + std::copy(std::begin(snapshot.fighters), std::end(snapshot.fighters), game.fighters); + std::copy(std::begin(snapshot.bots), std::end(snapshot.bots), game.bot.states); + game.fighterCount = snapshot.fighterCount; + game.weapons = snapshot.weapons; + game.world = snapshot.world; + game.rng = snapshot.rng; + game.match = snapshot.match; +} + +static void hash_add(Uint32& hash, float value) +{ + Uint32 bits; + std::memcpy(&bits, &value, sizeof(bits)); + hash = (hash ^ bits) * FNV_PRIME; +} + +// A fingerprint of the simulation state that matters for a desync: the fighters, every attack, the dummies, the +// match and the random generator. +Uint32 Rollback::checksum_get(Game& game) +{ + Uint32 hash = FNV_OFFSET; + hash_add(hash, (float)game.match.state + game.match.timer); + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + for (float value : {fighter.body.box.x, fighter.body.box.y, fighter.body.velocity.x, fighter.body.velocity.y, + fighter.aim.x, fighter.aim.y, fighter.charge, fighter.health, fighter.hitstop}) + hash_add(hash, value); + for (float cooldown : fighter.cooldowns) + hash_add(hash, cooldown); + // What the menu's edits change: loadouts, levels, slot counts, stacking and PvP. + for (int slot = 0; slot < SLOT_MAX; slot++) + for (int value : {fighter.weaponLoadout[slot], fighter.trinketLoadout[slot], fighter.weaponSlotLevels[slot], + fighter.trinketSlotLevels[slot]}) + hash_add(hash, (float)value); + for (int level : fighter.weaponItemLevels) + hash_add(hash, (float)level); + for (int level : fighter.trinketItemLevels) + hash_add(hash, (float)level); + for (int value : {fighter.weaponSlots, fighter.trinketSlots, (int)fighter.isPvp}) + hash_add(hash, (float)value); + } + for (Attack& attack : game.weapons.attacks) + for (float value : {attack.position.x, attack.position.y, attack.velocity.x, attack.velocity.y, attack.angle, + attack.time, (float)attack.hitsLeft}) + hash_add(hash, value); + for (Dummy& dummy : game.world.dummies) + for (float value : {dummy.flash, dummy.wobble, dummy.wobbleSpeed}) + hash_add(hash, value); + return (hash ^ game.rng.state) * FNV_PRIME; +} diff --git a/src/rollback.h b/src/rollback.h new file mode 100644 index 0000000..59b7100 --- /dev/null +++ b/src/rollback.h @@ -0,0 +1,46 @@ +#pragma once + +#include "bot.h" +#include "menu.h" +#include "input.h" +#include "match.h" +#include "fighter.h" +#include "trinket.h" +#include "weapon.h" +#include "world.h" + +struct Game; + +constexpr int ROLLBACK_DEPTH = 8; + +// Everything a simulation tick reads and writes, so the game can be rewound to it. +struct Snapshot +{ + BotState bots[MAX_FIGHTERS]; + Fighter fighters[MAX_FIGHTERS]; + Match match; + Weapons weapons; + World world; + Rng rng; + int fighterCount = 0; +}; + +// Steps the simulation. With RPG_ROLLBACK_TEST set it also keeps the last few ticks' snapshots, inputs and fighter +// controls, and every tick rewinds to the oldest, replays them muted and logs if the result differs from what happened +// live. +class Rollback +{ +public: + void init(); + void update(Game& game); + Snapshot snapshot_save(Game& game); + void snapshot_load(Game& game, Snapshot& snapshot); + Uint32 checksum_get(Game& game); + +private: + Snapshot snapshots[ROLLBACK_DEPTH]; + Controls controls[ROLLBACK_DEPTH][MAX_FIGHTERS]; + Input inputs[ROLLBACK_DEPTH]; + int tick = 0; + bool isTesting = false; +}; diff --git a/src/session.cpp b/src/session.cpp new file mode 100644 index 0000000..fbd3672 --- /dev/null +++ b/src/session.cpp @@ -0,0 +1,1417 @@ +#include "session.h" +#include "game.h" +#include "state.h" +#include +#include +#include +#include + +constexpr int ROLLBACK_LIMIT = 8; +constexpr int JOIN_LEAD = 60; +constexpr int INPUT_PACKET_MAX = 64; +constexpr int LEAVE_INPUTS_MAX = 128; +constexpr int CATCH_UP_MAX = 240; +constexpr int AHEAD_LIMIT = 2; +constexpr int BEHIND_LIMIT = 3; +constexpr int GUESS_REACH = 64; +constexpr int CHECKSUM_INTERVAL = 60; +constexpr float MS_PER_TICK = TICK * 1000; +constexpr Uint64 RENAME_WAIT = 30000; +constexpr Uint64 RENAME_SLACK = 2000; +// How far ahead the host sets a queue change, so it rarely lands on a tick someone's already played. +constexpr int QUEUE_LEAD = 15; +constexpr Uint32 ARENA_SEED_MIX = 2654435761u; + +enum PacketType : Uint8 +{ + PACKET_JOIN_REQUEST = 1, + PACKET_EVENT, + PACKET_STATE, + PACKET_CHECKSUM, + PACKET_INPUTS, + PACKET_CHAT, + PACKET_TYPING, + PACKET_RENAME, + PACKET_QUEUE +}; + +// Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). +struct PacketWriter +{ + std::string bytes; + + template PacketWriter& put(const T& value) + { + static_assert(std::is_trivially_copyable_v); + bytes.append(reinterpret_cast(&value), sizeof(T)); + return *this; + } +}; + +struct PacketReader +{ + const std::string& bytes; + size_t at = 0; + + template bool get(T& value) + { + static_assert(std::is_trivially_copyable_v); + if (at + sizeof(T) > bytes.size()) + return false; + std::memcpy(static_cast(&value), bytes.data() + at, sizeof(T)); + at += sizeof(T); + return true; + } +}; + +// Field by field, since the padding between them is whatever was in memory. +static bool controls_same(const Controls& a, const Controls& b) +{ + return std::memcmp(&a.cursor.x, &b.cursor.x, sizeof(float)) == 0 && + std::memcmp(&a.cursor.y, &b.cursor.y, sizeof(float)) == 0 && a.buttons == b.buttons && a.edit == b.edit && + a.slot == b.slot; +} + +InputLog::InputLog() +{ + std::fill(std::begin(ticks), std::end(ticks), -1); + std::fill(std::begin(usedTicks), std::end(usedTicks), -1); +} + +// Needed from a new tick on: what's certain is counted again from there. +void InputLog::restart(int from) +{ + start = from; + contiguous = from - 1; + while (has(contiguous + 1)) + contiguous++; +} + +bool InputLog::has(int at) const +{ + return at >= 0 && ticks[at % INPUT_RING] == at; +} + +// Keeps a tick's controls (the first to arrive; later copies are the same). Returns whether that tick was already +// simulated with a guess that turned out wrong. +bool InputLog::put(int at, const Controls& value) +{ + if (at < 0 || has(at)) + return false; + int i = at % INPUT_RING; + controls[i] = value; + ticks[i] = at; + latest = std::max(latest, at); + while (has(contiguous + 1)) + contiguous++; + return usedTicks[i] == at && !controls_same(used[i], value); +} + +// A guess at a tick's controls: the last ones known before it, held the same, but without a one-off edit or weapon +// switch (which would otherwise happen again every guessed tick). +Controls InputLog::guess(int at) const +{ + for (int back = at - 1; back >= std::max(start, at - GUESS_REACH); back--) + if (has(back)) + { + Controls guessed = controls[back % INPUT_RING]; + guessed.edit = -1; + guessed.slot = -1; + return guessed; + } + return {}; +} + +// This screen's player as they join: name, colour, and the sandbox fighter they're playing now. +Profile Session::profile_get(Game& game) +{ + Profile profile; + Fighter& fighter = game.local_get(); + SDL_strlcpy(profile.name, game.settings.name, sizeof(profile.name)); + profile.color = game.settings.color_get(); + profile.weaponSlots = fighter.weaponSlots; + profile.trinketSlots = fighter.trinketSlots; + std::copy(std::begin(fighter.weaponLoadout), std::end(fighter.weaponLoadout), profile.weaponLoadout); + std::copy(std::begin(fighter.trinketLoadout), std::end(fighter.trinketLoadout), profile.trinketLoadout); + std::copy(std::begin(fighter.weaponSlotLevels), std::end(fighter.weaponSlotLevels), profile.weaponSlotLevels); + std::copy(std::begin(fighter.trinketSlotLevels), std::end(fighter.trinketSlotLevels), profile.trinketSlotLevels); + std::copy(std::begin(fighter.weaponItemLevels), std::end(fighter.weaponItemLevels), profile.weaponItemLevels); + std::copy(std::begin(fighter.trinketItemLevels), std::end(fighter.trinketItemLevels), profile.trinketItemLevels); + return profile; +} + +// Starts afresh: no logs, peers, events or snapshots, and the input delay as set. +static void session_clear(Game& game, Session& session) +{ + session.tick = 0; + session.delay = game.settings.inputDelay; + session.rollbacks = 0; + session.stalls = 0; + session.isDesynced = false; +} + +// The lobby's maker plays on in their sandbox, now as the session's first fighter. +void Session::host_start(Game& game) +{ + session_clear(game, *this); + logs.clear(); + peers.clear(); + events.clear(); + departed.clear(); + remoteChecksums.clear(); + std::fill(std::begin(snapshotTicks), std::end(snapshotTicks), -1); + checksumSent = -1; + rollbackTo = -1; + isActive = true; + isJoined = true; + isRequested = true; + + Fighter own = game.local_get(); + for (Device& device : game.input.devices) + device.isClaimed = false; + game.fighterCount = 1; + game.fighters[0] = own; + game.fighters[0].index = 0; + game.weapons.attacks.clear(); + Match& match = game.match; + std::fill(std::begin(match.owners), std::end(match.owners), -1); + match.owners[0] = game.net.selfId; + SDL_strlcpy(match.names[0], game.settings.name, sizeof(match.names[0])); + match.colors[0] = std::max(0, game.settings.color_get()); + int self = game.net.selfId; + logs[self].restart(0); + ownStart = ownNext = 0; + names.clear(); + names[self] = text_clean(game.settings.name, PLAYER_NAME_MAX); + sentColor = game.settings.color_get(); + game.chat.players_clear(); + game.chat.system("Created lobby " + game.net.code + ". Press T to chat, or type /help for commands."); + local_find(game); +} + +// A newcomer waits to be connected to everyone, then asks the host to be let in (see members_watch). +void Session::join_start(Game& game) +{ + session_clear(game, *this); + logs.clear(); + peers.clear(); + events.clear(); + departed.clear(); + remoteChecksums.clear(); + std::fill(std::begin(snapshotTicks), std::end(snapshotTicks), -1); + checksumSent = -1; + rollbackTo = -1; + isActive = true; + isJoined = false; + isRequested = false; + names.clear(); + names[game.net.selfId] = text_clean(game.settings.name, PLAYER_NAME_MAX); + sentColor = game.settings.color_get(); + game.chat.players_clear(); + game.chat.system("Joining lobby " + game.net.code + "..."); +} + +// Back to playing alone: this screen's fighter (if it had one) stays, in a sandbox of its own. +void Session::stop(Game& game) +{ + if (!isActive) + return; + isActive = false; + int own = -1; + for (int i = 0; i < game.fighterCount; i++) + if (isJoined && game.match.owners[i] == game.net.selfId) + own = i; + if (own >= 0) + game.fighters[0] = game.fighters[own]; + game.fighters[0].index = 0; + game.fighterCount = 1; + game.weapons.attacks.clear(); + std::fill(std::begin(game.match.owners), std::end(game.match.owners), -1); + game.local = 0; + std::fill(std::begin(game.devices), std::end(game.devices), DEVICE_NONE); + game.devices[0] = DEVICE_KEYBOARD; + game.fighters[0].isBot = false; + Match& match = game.match; + std::fill(std::begin(match.queues), std::end(match.queues), Queue{}); + std::fill(std::begin(match.isAway), std::end(match.isAway), false); + std::fill(std::begin(match.queuedIn), std::end(match.queuedIn), -1); + std::fill(std::begin(match.isQueueReady), std::end(match.isQueueReady), false); + arena.isActive = false; + isWatching = false; + announced.clear(); + isJoined = false; + game.chat.players_clear(); +} + +// Whether this screen should run the shared game: it's in a lobby (or was, and is still to wind it down). +bool Session::is_wanted(Game& game) +{ + return isActive || game.net.state == NET_LOBBY; +} + +// Whether a member's controls count at a tick: their fighter is in the game then. +bool Session::is_needed(int member, int at) +{ + auto log = logs.find(member); + return log != logs.end() && at >= log->second.start && at < log->second.until; +} + +// The last tick whose state is certain: every member's controls before it are known. (A member whose fighter hasn't +// appeared yet doesn't hold it back.) Never older than the oldest snapshot kept. +int Session::confirmed_get(Game& game) +{ + int confirmed = tick; + for (auto& [member, log] : logs) + if (member != game.net.selfId && log.start < tick && log.until > log.contiguous + 1) + confirmed = std::min(confirmed, std::max(log.contiguous + 1, log.start)); + for (int oldest = confirmed; oldest < tick; oldest++) + if (snapshotTicks[oldest % SNAPSHOT_RING] == oldest) + return oldest; + return tick; +} + +// Once per real tick: takes in what's arrived, adds this screen's controls (delay ticks ahead), sends everyone what +// they're missing, rewinds over any wrong guess, then moves the game on: a tick, none if this machine is ahead of the +// others (or would be guessing too far), or several if it's behind (catching up after joining). +void Session::update(Game& game, Controls local) +{ + if (!isActive && game.net.state == NET_LOBBY) + { + if (game.net.members.size() <= 1) + host_start(game); + else + join_start(game); + } + if (!isActive) + return; + if (game.net.state != NET_LOBBY) + { + stop(game); + return; + } + members_watch(game); + packets_take(game); + if (!isJoined) + return; + profile_watch(game); + + int self = game.net.selfId; + InputLog& own = logs[self]; + ownNext = std::max(ownNext, ownStart); + bool isEdited = false; + auto own_fill = [&](int until) { + for (; ownNext <= until; ownNext++) + { + Controls held = local; + if (isEdited) + held.edit = held.slot = -1; + own.put(ownNext, held); + isEdited = true; + } + }; + own_fill(tick + delay); + inputs_send(game); + + if (rollbackTo >= 0 && rollbackTo < tick) + { + if (snapshotTicks[rollbackTo % SNAPSHOT_RING] != rollbackTo) + SDL_Log("Session: can't rewind to tick %d, it's gone", rollbackTo); + else + { + int end = tick; + game.rollback.snapshot_load(game, snapshots[rollbackTo % SNAPSHOT_RING]); + arena_restore(game, rollbackTo % SNAPSHOT_RING); + tick = rollbackTo; + while (tick < end) + step(game, false); + rollbacks++; + } + } + rollbackTo = -1; + + // How far ahead or behind the others this machine is, from the ticks they last reported (moved on by half the + // round trip). + int behind = 0; + bool isAhead = false; + for (auto& [member, peer] : peers) + { + if (!peer.isSending || !is_needed(member, tick)) + continue; + float ping = 0; + for (NetMember& netMember : game.net.members) + if (netMember.id == member && netMember.ping > 0) + ping = netMember.ping; + int estimate = peer.remoteTick + (int)(ping / 2 / MS_PER_TICK); + isAhead |= tick > estimate + AHEAD_LIMIT; + behind = std::max(behind, estimate - tick); + } + int advance = isAhead ? 0 : behind > BEHIND_LIMIT ? std::min(behind, CATCH_UP_MAX) : 1; + stalls += advance == 0; + for (int i = 0; i < advance; i++) + { + own_fill(tick + delay); + bool isReady = true; + for (auto& [member, log] : logs) + if (member != self && is_needed(member, tick) && tick - log.contiguous > ROLLBACK_LIMIT) + isReady = false; + if (!isReady) + { + stalls++; + break; + } + step(game, i == advance - 1); + } + checksums_exchange(game); + local_find(game); +} + +// Simulates one tick: keeps the state from before it (to rewind to), makes any change of players due then, gives +// every fighter its member's controls (known, or guessed), and steps the sandbox, then the arena if a queued game is +// being played (and starts one that's become ready). Replays are silent, as is whichever of the two isn't shown. +void Session::step(Game& game, bool isLive) +{ + int slot = tick % SNAPSHOT_RING; + snapshots[slot] = game.rollback.snapshot_save(game); + arena_save(game, slot); + snapshotTicks[slot] = tick; + checksums[slot] = checksum_full(game); + events_apply(game, tick); + + bool isArenaShown = arena.isActive && (isWatching || is_playing_arena(game)); + controls_give(game, false); + game.audio.isMuted = game.effects.isMuted = !isLive || isArenaShown; + game_tick(game); + if (arena.isActive) + { + arena_swap(game); + arena_locals_find(game); + controls_give(game, true); + game.audio.isMuted = game.effects.isMuted = !isLive || !isArenaShown; + game_tick(game); + arena_locals_find(game); + arena_swap(game); + arena_after(game); + } + game.audio.isMuted = game.effects.isMuted = false; + if (!arena.isActive) + { + int queuer = game.match.queue_startable_get(game); + if (queuer >= 0) + arena_start(game, queuer); + } + tick++; +} + +// Gives every fighter of the game in place (the sandbox, or the arena swapped in) its member's controls for this tick, +// known or guessed. In the sandbox, anyone away in the arena does nothing; in the arena, anyone who's left it does +// nothing, and anyone gone from the lobby altogether leaves it. +void Session::controls_give(Game& game, bool isArena) +{ + // While the arena's swapped in, the sandbox is what's set aside. + Match& lobby = isArena ? arena.state.match : game.match; + int lobbyCount = isArena ? arena.state.fighterCount : game.fighterCount; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + int owner = game.match.owners[i]; + bool isIdle = isArena ? owner >= 0 && game.match.isGone[i] : game.match.isAway[i]; + auto log = logs.find(owner); + if (isIdle || owner < 0 || log == logs.end()) + { + fighter.controls = {}; + continue; + } + if (isArena && std::find(lobby.owners, lobby.owners + lobbyCount, owner) == lobby.owners + lobbyCount) + { + fighter.controls = {.edit = edit_encode({.type = EDIT_LEAVE})}; + continue; + } + InputLog& inputs = log->second; + int ring = tick % INPUT_RING; + if (inputs.has(tick)) + { + fighter.controls = inputs.controls[ring]; + inputs.usedTicks[ring] = -1; + continue; + } + fighter.controls = inputs.guess(tick); + inputs.used[ring] = fighter.controls; + inputs.usedTicks[ring] = tick; + } +} + +// Adds and removes fighters as the events due at a tick say. A newcomer's fighter starts from their profile, in the +// colour they asked for if it's free; one leaving takes its attacks with it, and the fighters after it move down. +void Session::events_apply(Game& game, int at) +{ + Match& match = game.match; + for (SessionEvent& event : events) + { + if (event.tick != at) + continue; + if (event.type == EVENT_JOIN && game.fighterCount < MAX_FIGHTERS) + { + int index = game.fighterCount++; + Fighter& fighter = game.fighters[index]; + fighter = Fighter{}; + fighter.init(game, index); + const Profile& profile = event.profile; + fighter.slots_set(std::clamp(profile.weaponSlots, 1, SLOT_MAX), + std::clamp(profile.trinketSlots, 1, SLOT_MAX)); + std::copy(std::begin(profile.weaponLoadout), std::end(profile.weaponLoadout), fighter.weaponLoadout); + std::copy(std::begin(profile.trinketLoadout), std::end(profile.trinketLoadout), fighter.trinketLoadout); + std::copy(std::begin(profile.weaponSlotLevels), std::end(profile.weaponSlotLevels), + fighter.weaponSlotLevels); + std::copy(std::begin(profile.trinketSlotLevels), std::end(profile.trinketSlotLevels), + fighter.trinketSlotLevels); + std::copy(std::begin(profile.weaponItemLevels), std::end(profile.weaponItemLevels), + fighter.weaponItemLevels); + std::copy(std::begin(profile.trinketItemLevels), std::end(profile.trinketItemLevels), + fighter.trinketItemLevels); + match.owners[index] = event.member; + SDL_strlcpy(match.names[index], profile.name, sizeof(match.names[index])); + bool isTaken[COLOR_COUNT] = {}; + for (int i = 0; i < index; i++) + isTaken[match.colors[i]] = true; + int color = profile.color >= 0 && profile.color < COLOR_COUNT && !isTaken[profile.color] + ? profile.color + : std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken; + match.colors[index] = color % COLOR_COUNT; + } + if (event.type == EVENT_LEAVE) + { + int index = std::find(match.owners, match.owners + game.fighterCount, event.member) - match.owners; + if (index >= game.fighterCount) + continue; + std::vector& attacks = game.weapons.attacks; + std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); + for (Attack& attack : attacks) + attack.owner -= attack.owner > index; + for (int i = index; i + 1 < game.fighterCount; i++) + { + game.fighters[i] = game.fighters[i + 1]; + game.fighters[i].index = i; + game.bot.states[i] = game.bot.states[i + 1]; + match.owners[i] = match.owners[i + 1]; + match.colors[i] = match.colors[i + 1]; + SDL_memcpy(match.names[i], match.names[i + 1], sizeof(match.names[i])); + } + match.queue_slots_shift(index, game.fighterCount); + game.fighterCount--; + match.owners[game.fighterCount] = -1; + } + if (event.type == EVENT_QUEUE || event.type == EVENT_UNQUEUE) + { + int index = std::find(match.owners, match.owners + game.fighterCount, event.member) - match.owners; + if (index >= game.fighterCount || match.isAway[index]) + continue; + if (event.type == EVENT_QUEUE) + match.queue_open(game, index, event.rules); + else + { + match.queues[index].isActive = false; + match.isQueueReady[index] = false; + } + } + } +} + +// Joins and leaves the server reports. A newcomer, once connected to everyone, asks the host to come in. The host +// takes out anyone gone (a new host does the same for anyone who left with the old one). +void Session::members_watch(Game& game) +{ + Net& net = game.net; + int self = net.selfId; + if (!isJoined && !isRequested && net.is_connected_all() && net.hostId != self) + { + Profile profile = profile_get(game); + net.send(net.hostId, PacketWriter().put(PACKET_JOIN_REQUEST).put(profile).bytes, true); + isRequested = true; + } + if (!isJoined || net.hostId != self) + return; + for (auto& [member, log] : logs) + { + bool isPresent = + std::any_of(net.members.begin(), net.members.end(), [&](NetMember& m) { return m.id == member; }); + bool isDone = std::find(departed.begin(), departed.end(), member) != departed.end(); + if (!isPresent && !isDone && member != self) + { + departed.push_back(member); + leave_decide(game, member); + } + } +} + +void Session::packets_take(Game& game) +{ + std::vector packets; + packets.swap(game.net.packets); + for (NetPacket& packet : packets) + packet_handle(game, packet.from, packet.bytes, packet.isReliable); +} + +void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isReliable) +{ + PacketReader reader = {bytes}; + PacketType type; + if (!reader.get(type)) + return; + + if (type == PACKET_INPUTS && !isReliable) + { + int senderTick = 0; + int ack = 0; + int first = 0; + Uint16 count = 0; + if (!reader.get(senderTick) || !reader.get(ack) || !reader.get(first) || !reader.get(count)) + return; + SessionPeer& peer = peers[from]; + peer.remoteTick = std::max(peer.remoteTick, senderTick); + peer.ack = std::max(peer.ack, ack); + InputLog& log = logs[from]; + for (int i = 0; i < count; i++) + { + Controls controls; + if (!reader.get(controls)) + return; + int at = first + i; + if (log.put(at, controls) && at < tick) + rollbackTo = rollbackTo < 0 ? at : std::min(rollbackTo, at); + } + return; + } + if (type == PACKET_QUEUE) + { + Uint8 isOpen = 0; + Rules rules; + if (reader.get(isOpen) && reader.get(rules) && isJoined && game.net.hostId == game.net.selfId) + queue_decide(game, from, isOpen != 0, rules); + return; + } + if (type == PACKET_JOIN_REQUEST) + { + Profile profile; + if (reader.get(profile) && isJoined && game.net.hostId == game.net.selfId) + join_grant(game, from, profile); + return; + } + if (type == PACKET_EVENT) + { + SessionEvent event; + int first = 0; + Uint16 count = 0; + if (!reader.get(event) || !reader.get(first) || !reader.get(count)) + return; + InputLog& log = logs[event.member]; + for (int i = 0; i < count; i++) + { + Controls controls; + if (!reader.get(controls)) + return; + if (log.put(first + i, controls) && first + i < tick) + rollbackTo = rollbackTo < 0 ? first + i : std::min(rollbackTo, first + i); + } + event_add(game, event); + return; + } + if (type == PACKET_STATE && !isJoined) + { + int at = 0; + if (!reader.get(at)) + return; + if (!state_full_load(game, bytes.substr(reader.at))) + { + SDL_Log("Session: the host's game state didn't load (a different version?)"); + game.net.error = "Couldn't load the lobby's game (different versions?)"; + return; + } + tick = at; + isJoined = true; + std::erase_if(events, [&](SessionEvent& event) { return event.tick < at; }); + int self = game.net.selfId; + for (int i = 0; i < game.fighterCount; i++) + { + int owner = game.match.owners[i]; + if (owner >= 0 && owner != self && !names.contains(owner)) + names[owner] = text_clean(game.match.names[i], PLAYER_NAME_MAX); + if (owner < 0 || owner == self) + continue; + logs[owner].restart(at); + peers[owner].isSending = true; + peers[owner].sendFrom = std::max(peers[owner].sendFrom, ownStart); + } + local_find(game); + return; + } + if (type == PACKET_CHAT || type == PACKET_RENAME) + { + // Text from another player is cleaned before it's used (see text_clean), whatever their game sent. + Uint8 isQuiet = 0; + Uint16 length = 0; + if ((type == PACKET_RENAME && !reader.get(isQuiet)) || !reader.get(length) || reader.at + length > bytes.size()) + return; + std::string_view raw(bytes.data() + reader.at, length); + if (type == PACKET_CHAT) + { + std::string text = text_clean(raw, CHAT_MESSAGE_MAX); + if (!text.empty()) + game.chat.say(game, from, text); + return; + } + std::string name = text_clean(raw, PLAYER_NAME_MAX); + if (name.empty() || name == names[from]) + return; + // A rename sooner than they're allowed (a game not keeping to the wait) is ignored. + Uint64 now = SDL_GetTicks(); + if (!isQuiet && renamedAt.contains(from) && now < renamedAt[from] + RENAME_WAIT - RENAME_SLACK) + return; + if (!isQuiet) + renamedAt[from] = now; + std::string before = name_get(game, from); + names[from] = name; + for (NetMember& member : game.net.members) + if (member.id == from) + member.name = name; + if (!isQuiet) + game.chat.system(before + " changed their name to " + name_get(game, from) + "."); + return; + } + if (type == PACKET_TYPING) + { + Uint8 isTyping = 0; + if (reader.get(isTyping)) + game.chat.typing[from] = isTyping != 0; + return; + } + if (type == PACKET_CHECKSUM) + { + int at = 0; + Uint32 value = 0; + if (reader.get(at) && reader.get(value)) + remoteChecksums[at] = value; + } +} + +// The host lets a newcomer in: they get the game as of the last certain tick, every other member is told when the +// newcomer's fighter appears (a moment from now) and to send the newcomer their controls from that certain tick on. +void Session::join_grant(Game& game, int member, const Profile& profile) +{ + int from = confirmed_get(game); + SessionEvent event = { + .tick = tick + JOIN_LEAD, .type = EVENT_JOIN, .member = member, .from = from, .profile = profile}; + std::string state; + if (from == tick) + state = state_full_save(game); + else + { + Snapshot now = game.rollback.snapshot_save(game); + Arena arenaNow = arena; + game.rollback.snapshot_load(game, snapshots[from % SNAPSHOT_RING]); + arena_restore(game, from % SNAPSHOT_RING); + state = state_full_save(game); + game.rollback.snapshot_load(game, now); + arena = arenaNow; + } + event_add(game, event); + for (auto& [other, log] : logs) + if (other != game.net.selfId) + event_send(game, other, event, 0, -1); + for (SessionEvent& pending : events) + if (pending.tick >= from && !(pending.member == member && pending.type == EVENT_JOIN)) + event_send(game, member, pending, 0, -1); + PacketWriter packet; + packet.put(PACKET_STATE).put(from); + packet.bytes += state; + game.net.send(member, packet.bytes, true); + SDL_Log("Session: %d joins at tick %d, from tick %d (%zu bytes)", member, event.tick, from, state.size()); +} + +// The host takes a member out at the tick after the last of their controls it has, and sends everyone those last +// controls, so all agree what the fighter did before it went. +void Session::leave_decide(Game& game, int member) +{ + InputLog& log = logs[member]; + int at = std::max(log.contiguous + 1, log.start); + SessionEvent event = {.tick = at, .type = EVENT_LEAVE, .member = member}; + event_add(game, event); + int from = std::max(log.start, at - LEAVE_INPUTS_MAX); + for (auto& [other, otherLog] : logs) + if (other != game.net.selfId && other != member) + event_send(game, other, event, from, at - 1); + SDL_Log("Session: %d leaves at tick %d", member, at); +} + +// Sends an event, with the member's controls from one tick to another (none when from is past to). +void Session::event_send(Game& game, int to, const SessionEvent& event, int inputsFrom, int inputsTo) +{ + InputLog& log = logs[event.member]; + std::vector inputs; + for (int at = inputsFrom; at <= inputsTo && log.has(at); at++) + inputs.push_back(log.controls[at % INPUT_RING]); + PacketWriter packet; + packet.put(PACKET_EVENT).put(event).put(inputsFrom).put((Uint16)inputs.size()); + for (Controls& controls : inputs) + packet.put(controls); + game.net.send(to, packet.bytes, true); +} + +// Takes an event in: a join says when the newcomer's controls start counting and that they need this screen's from +// the certain tick on; a leave, when the member's stop. One for a tick already played rewinds to it. +void Session::event_add(Game& game, const SessionEvent& event) +{ + bool isQueueing = event.type == EVENT_QUEUE || event.type == EVENT_UNQUEUE; + for (SessionEvent& known : events) + if (known.member == event.member && known.type == event.type && (!isQueueing || known.tick == event.tick)) + return; + events.push_back(event); + std::stable_sort(events.begin(), events.end(), [](const SessionEvent& a, const SessionEvent& b) { + return a.tick != b.tick ? a.tick < b.tick : a.type < b.type; + }); + int self = game.net.selfId; + if (event.type == EVENT_JOIN) + { + if (event.member != self) + names[event.member] = text_clean(event.profile.name, PLAYER_NAME_MAX); + game.chat.system(name_get(game, event.member) + " joined the lobby."); + // A newcomer only knows the names the game had when it started, so everyone tells them theirs now. + if (event.member != self && isJoined) + rename_send(game, event.member, true); + logs[event.member].restart(event.tick); + if (event.member == self) + ownStart = ownNext = event.tick; + else + { + SessionPeer& peer = peers[event.member]; + peer.isSending = true; + peer.sendFrom = std::max(event.from, ownStart); + } + } + if (event.type == EVENT_QUEUE) + { + Rules rules = rules_clean(event.rules); + game.chat.system(name_get(game, event.member) + " is hosting a Deathmatch for " + + std::to_string(rules.players) + " players."); + } + if (event.type == EVENT_LEAVE) + { + auto kick = game.net.kicks.find(event.member); + if (kick == game.net.kicks.end()) + game.chat.system(name_get(game, event.member) + " left the lobby."); + else + game.chat.system(name_get(game, event.member) + " was kicked by the host." + + (kick->second.empty() ? "" : " Reason: " + kick->second)); + logs[event.member].until = event.tick; + peers[event.member].isSending = false; + } + if (isJoined && event.tick < tick) + rollbackTo = rollbackTo < 0 ? event.tick : std::min(rollbackTo, event.tick); +} + +// Sends each member this screen's controls they don't have yet (from the last they confirmed, up to a packetful), +// with this screen's tick and how far it has theirs, for timing and acknowledging. Sent every tick even when empty. +void Session::inputs_send(Game& game) +{ + int self = game.net.selfId; + InputLog& own = logs[self]; + for (auto& [member, peer] : peers) + { + if (!peer.isSending || member == self) + continue; + int first = std::max({peer.ack + 1, peer.sendFrom, ownStart}); + int last = std::min(ownNext - 1, first + INPUT_PACKET_MAX - 1); + Uint16 count = last >= first ? last - first + 1 : 0; + PacketWriter packet; + packet.put(PACKET_INPUTS).put(tick).put(logs[member].contiguous).put(first).put(count); + for (int at = first; at < first + count; at++) + packet.put(own.controls[at % INPUT_RING]); + game.net.send(member, packet.bytes, false); + } +} + +// Every CHECKSUM_INTERVAL ticks, once a tick is certain, this screen's checksum of it goes to everyone and is compared +// with theirs: a difference means the machines' games have drifted apart. +void Session::checksums_exchange(Game& game) +{ + int confirmed = confirmed_get(game); + for (int at = (checksumSent / CHECKSUM_INTERVAL + 1) * CHECKSUM_INTERVAL; at <= confirmed; at += CHECKSUM_INTERVAL) + { + if (snapshotTicks[at % SNAPSHOT_RING] != at) + continue; + checksumSent = at; + PacketWriter packet; + packet.put(PACKET_CHECKSUM).put(at).put(checksums[at % SNAPSHOT_RING]); + for (auto& [member, peer] : peers) + if (peer.isSending) + game.net.send(member, packet.bytes, true); + } + for (auto entry = remoteChecksums.begin(); entry != remoteChecksums.end();) + { + auto [at, value] = *entry; + if (at > confirmed) + break; + if (snapshotTicks[at % SNAPSHOT_RING] == at && checksums[at % SNAPSHOT_RING] != value && !isDesynced) + { + isDesynced = true; + SDL_Log("Session: desync at tick %d (%08x here, %08x there)", at, checksums[at % SNAPSHOT_RING], value); + } + entry = remoteChecksums.erase(entry); + } +} + +// Which fighter is this screen's (the first, while it hasn't appeared yet), and that only it takes the keyboard. +void Session::local_find(Game& game) +{ + game.local = 0; + for (int i = 0; i < MAX_FIGHTERS; i++) + { + bool isOwn = i < game.fighterCount && game.match.owners[i] == game.net.selfId; + game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; + game.fighters[i].isBot = false; + if (isOwn) + game.local = i; + } +} + +// Which fighter a member plays, or -1 if they have none (yet). +int Session::fighter_get(Game& game, int member) +{ + for (int i = 0; i < game.fighterCount; i++) + if (game.match.owners[i] == member) + return i; + return -1; +} + +// A member's name as they chose it, or the server's for them until it's known. +std::string Session::base_name_get(Game& game, int member) +{ + auto name = names.find(member); + if (name != names.end()) + return name->second; + for (NetMember& netMember : game.net.members) + if (netMember.id == member) + return text_clean(netMember.name, PLAYER_NAME_MAX); + return "Player"; +} + +// A member's name as shown: numbered when it repeats an earlier player's (see Ui::name_get). One whose fighter hasn't +// appeared yet (just joining) is numbered as it will be, after everyone already in. +std::string Session::name_get(Game& game, int member) +{ + int fighter = fighter_get(game, member); + if (fighter >= 0) + return game.ui.name_get(game, fighter); + std::string name = base_name_get(game, member); + int same = 1; + for (int i = 0; i < game.fighterCount; i++) + same += game.match.owners[i] >= 0 && base_name_get(game, game.match.owners[i]) == name; + return same > 1 ? name + " (" + std::to_string(same) + ")" : name; +} + +// Sends a message to every other member over the reliable channel. +void Session::broadcast(Game& game, const std::string& bytes) +{ + for (NetMember& member : game.net.members) + if (member.id != game.net.selfId) + game.net.send(member.id, bytes, true); +} + +// A chat message from this screen: to everyone (when there's anyone), and into this screen's own log. +void Session::chat_send(Game& game, const std::string& text) +{ + if (!isActive) + return game.chat.say(game, game.net.selfId, text); + PacketWriter packet; + packet.put(PACKET_CHAT).put((Uint16)text.size()); + packet.bytes += text; + broadcast(game, packet.bytes); + game.chat.say(game, game.net.selfId, text); +} + +// Tells everyone this screen's chat box opened or closed (for the "..." over its fighter). +void Session::typing_send(Game& game, bool isTyping) +{ + if (isActive) + broadcast(game, PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes); +} + +// Sends this screen's name to one member (or everyone, for -1). A quiet one isn't announced in the chat. +void Session::rename_send(Game& game, int to, bool isQuiet) +{ + std::string& name = names[game.net.selfId]; + PacketWriter packet; + packet.put(PACKET_RENAME).put((Uint8)isQuiet).put((Uint16)name.size()); + packet.bytes += name; + if (to < 0) + broadcast(game, packet.bytes); + else + game.net.send(to, packet.bytes, true); +} + +// Notices this screen's name or colour changing (in Customize, or by a chat command). A new name goes to everyone once +// it's done being typed, and is announced; a new colour goes through the game as an edit, so every machine agrees +// (it's refused if someone else has it). +void Session::profile_watch(Game& game) +{ + int self = game.net.selfId; + std::string name = text_clean(game.settings.name, PLAYER_NAME_MAX); + if (!name.empty() && name != names[self] && !game.ui.is_active(game.settings.name)) + { + if (SDL_GetTicks() < nextRename) + { + if (!isRenameWaiting) + game.chat.system("Name changes are limited to once every 30 seconds. Yours will update in " + + seconds_say(rename_wait_get()) + "."); + isRenameWaiting = true; + return; + } + isRenameWaiting = false; + nextRename = SDL_GetTicks() + RENAME_WAIT; + std::string before = name_get(game, self); + names[self] = name; + game.chat.system(before + " changed their name to " + name_get(game, self) + "."); + rename_send(game, -1, false); + } + int color = game.settings.color_get(); + if (color >= 0 && color != sentColor) + { + sentColor = color; + if (fighter_get(game, self) >= 0) + game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_COLOR, .value = color})); + } +} + +// Seconds until this screen's name can change again in the lobby (0 when it can now, or offline). +int Session::rename_wait_get() +{ + Uint64 now = SDL_GetTicks(); + return isActive && now < nextRename ? (int)((nextRename - now + 999) / 1000) : 0; +} + +// The host puts a player out of the lobby by the name they show as (any case; a bare name works when only one player +// has it). The server does the kicking, and everyone's chat says so. +void Session::kick(Game& game, const std::string& name, const std::string& reason) +{ + if (!isActive || game.net.hostId != game.net.selfId) + return game.chat.system("Only the host can kick players."); + int found = -1; + int bare = -1; + int bareCount = 0; + for (NetMember& member : game.net.members) + { + if (member.id == game.net.selfId) + continue; + if (SDL_strcasecmp(name_get(game, member.id).c_str(), name.c_str()) == 0) + found = member.id; + if (SDL_strcasecmp(base_name_get(game, member.id).c_str(), name.c_str()) == 0) + { + bare = member.id; + bareCount++; + } + } + if (found < 0 && bareCount == 1) + found = bare; + if (found < 0) + return game.chat.system(bareCount > 1 ? "More than one player is named " + name + + ". Include their number, like \"" + name + " (2)\"." + : "There's no player named " + name + " in this lobby."); + game.net.kick_send(found, text_clean(reason, KICK_REASON_MAX)); +} + +// Asks the host to queue a game with these rules in the lobby's sandbox (this screen's fighter standing in its area), +// or to stop queueing it. +void Session::queue_send(Game& game, const Rules& rules) +{ + if (!isJoined) + return; + if (game.net.hostId == game.net.selfId) + return queue_decide(game, game.net.selfId, true, rules); + game.net.send(game.net.hostId, PacketWriter().put(PACKET_QUEUE).put((Uint8)1).put(rules).bytes, true); +} + +void Session::unqueue_send(Game& game) +{ + if (!isJoined) + return; + if (game.net.hostId == game.net.selfId) + return queue_decide(game, game.net.selfId, false, {}); + game.net.send(game.net.hostId, PacketWriter().put(PACKET_QUEUE).put((Uint8)0).put(Rules{}).bytes, true); +} + +// The host sets a queue change for a moment from now and tells everyone. +void Session::queue_decide(Game& game, int member, bool isOpen, const Rules& rules) +{ + SessionEvent event = {.tick = tick + QUEUE_LEAD, + .type = isOpen ? EVENT_QUEUE : EVENT_UNQUEUE, + .member = member, + .rules = rules_clean(rules)}; + event_add(game, event); + for (auto& [other, log] : logs) + if (other != game.net.selfId) + event_send(game, other, event, 0, -1); +} + +// Whether this screen's fighter has a game queued in the sandbox. +bool Session::is_queued(Game& game) +{ + Match& lobby = isSwapped ? arena.state.match : game.match; + int count = isSwapped ? arena.state.fighterCount : game.fighterCount; + for (int i = 0; i < count; i++) + if (lobby.owners[i] == game.net.selfId) + return lobby.queues[i].isActive; + return false; +} + +bool Session::is_arena_on() +{ + return isActive && arena.isActive; +} + +const Arena& Session::arena_get() +{ + return arena; +} + +// Whether this screen's player is in the arena's game and hasn't left it. +bool Session::is_playing_arena(Game& game) +{ + if (!arena.isActive) + return false; + Match& match = isSwapped ? game.match : arena.state.match; + int count = isSwapped ? game.fighterCount : arena.state.fighterCount; + for (int i = 0; i < count; i++) + if (match.owners[i] == game.net.selfId && !match.isGone[i]) + return true; + return false; +} + +bool Session::is_watching() +{ + return isWatching && arena.isActive; +} + +void Session::watch_start(Game& game) +{ + isWatching = arena.isActive && !is_playing_arena(game); +} + +void Session::watch_stop(Game&) +{ + isWatching = false; +} + +// Leaves the arena's game (forfeiting it if it's still on), through this screen's controls so it happens on the same +// tick everywhere. +void Session::arena_leave(Game& game) +{ + game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_LEAVE})); +} + +// The arena's players still in it, by name: "Sneed, Bob and Alice". +std::string Session::arena_names_get(Game& game) +{ + Match& match = isSwapped ? game.match : arena.state.match; + int count = isSwapped ? game.fighterCount : arena.state.fighterCount; + std::vector names; + for (int i = 0; i < count; i++) + if (match.owners[i] >= 0 && !match.isGone[i]) + names.push_back(base_name_get(game, match.owners[i])); + std::string text; + for (size_t i = 0; i < names.size(); i++) + text += (i == 0 ? "" : i + 1 == names.size() ? " and " : ", ") + names[i]; + return text; +} + +// Swaps the arena's game with the one in place: its fighters, match, attacks, world, random generator and bots (with +// their map), and which fighter is this screen's. +void Session::arena_swap(Game& game) +{ + std::swap(game.fighters, arena.state.fighters); + std::swap(game.fighterCount, arena.state.fighterCount); + std::swap(game.match, arena.state.match); + std::swap(game.weapons, arena.state.weapons); + std::swap(game.world, arena.state.world); + std::swap(game.rng, arena.state.rng); + std::swap(game.bot, arena.bot); + std::swap(game.devices, arena.devices); + std::swap(game.local, arena.local); +} + +// With the arena swapped in: this screen's fighter in it (the keyboard's), if it's playing; watching, the first. +void Session::arena_locals_find(Game& game) +{ + game.local = 0; + for (int i = 0; i < MAX_FIGHTERS; i++) + { + bool isOwn = i < game.fighterCount && game.match.owners[i] == game.net.selfId && !game.match.isGone[i]; + game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; + if (isOwn) + game.local = i; + } +} + +// The queued game is ready: everyone in its area goes into a fresh arena to play it, and the queue's done. Its players' +// chat starts afresh for it. +void Session::arena_start(Game& game, int queuer) +{ + Match& lobby = game.match; + int members[MAX_FIGHTERS]; + int count = lobby.queue_members_get(game, queuer, members); + Rules rules = lobby.queues[queuer].rules; + lobby.queues[queuer].isActive = false; + arena_build(game, rules, members, count); + std::string key = "start:" + std::to_string(arena.serial); + if (!announced.contains(key) && is_playing_arena(game)) + game.chat.clear(); + announce(game, key, "A Deathmatch began: " + arena_names_get(game) + "."); +} + +// Builds the arena for these sandbox fighters (by index) to play by the rules, bots filling the rest if the rules say +// to: their players, names and colours come along, and they're away from the sandbox while it lasts. It starts from +// a seed of the sandbox's random generator and the tick, so every machine builds the same one. +void Session::arena_build(Game& game, const Rules& rules, const int* members, int count) +{ + Match& lobby = game.match; + int owners[MAX_FIGHTERS]; + int colors[MAX_FIGHTERS]; + char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1]; + for (int k = 0; k < count; k++) + { + int i = members[k]; + owners[k] = lobby.owners[i]; + colors[k] = lobby.colors[i]; + SDL_memcpy(names[k], lobby.names[i], sizeof(names[k])); + lobby.isAway[i] = true; + lobby.isQueueReady[i] = false; + lobby.queuedIn[i] = -1; + lobby.queues[i].isActive = false; + std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + } + Uint32 seed = game.rng.state ^ (Uint32)tick * ARENA_SEED_MIX; + int players = rules.isBotFill ? std::max(rules.players, count) : count; + int botLevel = game.bot.level; + + arena.isActive = true; + arena.serial = tick; + arena.state = Snapshot{}; + arena.bot = Bot{}; + arena.bot.level = botLevel; + arena_swap(game); + Match& match = game.match; + match = Match{}; + match.rules = rules; + match.rules.players = players; + game.rng.state = seed ? seed : RNG_SEED; + game.fighterCount = players; + bool isTaken[COLOR_COUNT] = {}; + for (int k = 0; k < count; k++) + isTaken[colors[k] % COLOR_COUNT] = true; + for (int k = 0; k < players; k++) + { + game.fighters[k] = Fighter{}; + game.fighters[k].init(game, k); + match.owners[k] = k < count ? owners[k] : -1; + if (k < count) + { + match.colors[k] = colors[k]; + SDL_memcpy(match.names[k], names[k], sizeof(match.names[k])); + continue; + } + match.colors[k] = std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken; + isTaken[match.colors[k]] = true; + } + arena_locals_find(game); + match.start(game); + arena.graphSerial = arena.serial; + arena_locals_find(game); + arena_swap(game); +} + +// After the arena's tick: anyone who left it is back in the sandbox (and it's said so); with no players left, it's +// over; and once a finished match's players all ask to play again, it starts afresh with them. +void Session::arena_after(Game& game) +{ + Match& lobby = game.match; + Match& match = arena.state.match; + int count = arena.state.fighterCount; + int members[MAX_FIGHTERS]; + int humans = 0; + bool isAgain = match.state == MATCH_OVER; + for (int k = 0; k < count; k++) + { + int owner = match.owners[k]; + if (owner < 0) + continue; + int i = fighter_get(game, owner); + if (match.isGone[k]) + { + if (i >= 0 && lobby.isAway[i]) + { + lobby.isAway[i] = false; + announce(game, "left:" + std::to_string(arena.serial) + ":" + std::to_string(owner), + name_get(game, owner) + (match.isForfeited[k] ? " forfeited the match." : " left the match.")); + } + continue; + } + isAgain &= match.isReady[k] && i >= 0; + if (i >= 0) + members[humans] = i; + humans++; + } + if (humans == 0) + { + arena_end(game); + return; + } + // Set to play again, it waits a moment (as a queue does) before starting afresh. + bool isSet = isAgain && (humans >= PLAYERS_MIN || match.rules.isBotFill); + match.againTicks = isSet ? match.againTicks + 1 : 0; + if (match.againTicks < QUEUE_START_TICKS) + return; + Rules rules = match.rules; + if (!rules.isBotFill) + rules.players = humans; + arena_build(game, rules, members, humans); + announce(game, "again:" + std::to_string(arena.serial), "Playing again: " + arena_names_get(game) + "."); +} + +// The arena's game is over and everyone's back in the sandbox. +void Session::arena_end(Game& game) +{ + arena.isActive = false; + isWatching = false; + std::fill(std::begin(game.match.isAway), std::end(game.match.isAway), false); +} + +// Keeps the arena as it is before a tick, beside the sandbox's snapshot, to rewind to (none if it's not on). +void Session::arena_save(Game& game, int slot) +{ + arenaSerials[slot] = arena.isActive ? arena.serial : -1; + if (!arena.isActive) + return; + arena_swap(game); + arenaSnapshots[slot] = game.rollback.snapshot_save(game); + arena_swap(game); +} + +// Puts the arena back as it was kept for a tick (mapping it again for the bots if it's another one than they know). +void Session::arena_restore(Game& game, int slot) +{ + int serial = arenaSerials[slot]; + arena.isActive = serial >= 0; + if (!arena.isActive) + return; + arena.serial = serial; + arena_swap(game); + game.rollback.snapshot_load(game, arenaSnapshots[slot]); + if (arena.graphSerial != serial) + game.bot.graph_build(game.world); + arena.graphSerial = serial; + arena_locals_find(game); + arena_swap(game); +} + +// The checksum of the sandbox and, while it's on, the arena. +Uint32 Session::checksum_full(Game& game) +{ + Uint32 hash = game.rollback.checksum_get(game); + if (!arena.isActive) + return hash; + arena_swap(game); + hash = (hash ^ game.rollback.checksum_get(game)) * ARENA_SEED_MIX + (Uint32)arena.serial; + arena_swap(game); + return hash; +} + +// The whole shared state for a newcomer: the sandbox's, then the arena's (after the tick it started) if it's on. +std::string Session::state_full_save(Game& game) +{ + std::string lobby = state_save(game); + PacketWriter packet; + packet.put((Uint32)lobby.size()); + packet.bytes += lobby; + packet.put(arena.isActive ? arena.serial : -1); + if (!arena.isActive) + return packet.bytes; + arena_swap(game); + packet.bytes += state_save(game); + arena_swap(game); + return packet.bytes; +} + +bool Session::state_full_load(Game& game, const std::string& bytes) +{ + PacketReader reader = {bytes}; + Uint32 length = 0; + if (!reader.get(length) || reader.at + length > bytes.size()) + return false; + std::string lobby = bytes.substr(reader.at, length); + reader.at += length; + int serial = -1; + if (!reader.get(serial) || !state_load(game, lobby)) + return false; + arena.isActive = serial >= 0; + if (!arena.isActive) + return true; + arena.serial = serial; + arena.bot = Bot{}; + arena.bot.level = game.bot.level; + arena_swap(game); + bool isLoaded = state_load(game, bytes.substr(reader.at)); + arena.graphSerial = serial; + arena_locals_find(game); + arena_swap(game); + arena.isActive = isLoaded; + return isLoaded; +} + +// Says something in the chat once for a happening in the game, however many times replays go over it. +void Session::announce(Game& game, const std::string& key, const std::string& text) +{ + if (announced.insert(key).second) + game.chat.system(text); +} + +// Swaps the arena into the game for this screen's own work (its menu, drawing and camera) while it plays in it or +// watches it; returns whether it did. A change of what's shown starts its effects and camera afresh. +bool Session::view_enter(Game& game) +{ + if (isSwapped || !isActive) + return isSwapped; + bool isArena = arena.isActive && (isWatching || is_playing_arena(game)); + int shown = isArena ? arena.serial : -1; + if (shown != shownSerial) + { + shownSerial = shown; + isViewChanged = true; + game.effects.clear(); + game.ui.spectated = -1; + } + if (!isArena) + return false; + arena_swap(game); + isSwapped = true; + return true; +} + +void Session::view_leave(Game& game) +{ + if (!isSwapped) + return; + arena_swap(game); + isSwapped = false; +} + +// How long a member's connection takes there and back, in milliseconds: another member's from this screen, and this +// screen's own to the host (none when it's the host). Below 0 while it's not known yet. +float Session::ping_get(Game& game, int member) +{ + Net& net = game.net; + if (member < 0 || (member == net.selfId && net.hostId == net.selfId)) + return PING_NONE; + int to = member == net.selfId ? net.hostId : member; + for (NetMember& netMember : net.members) + if (netMember.id == to) + return netMember.isConnected ? netMember.ping : -1; + return PING_NONE; +} diff --git a/src/session.h b/src/session.h new file mode 100644 index 0000000..147be20 --- /dev/null +++ b/src/session.h @@ -0,0 +1,224 @@ +#pragma once + +#include "bot.h" +#include "input.h" +#include "match.h" +#include "rollback.h" +#include "trinket.h" +#include "weapon.h" +#include +#include +#include +#include + +struct Game; + +constexpr int INPUT_RING = 1024; +constexpr int SNAPSHOT_RING = 64; +// A member's connection has no ping to show (it's this screen's own, hosting). +constexpr float PING_NONE = -2; + +// A player's setup as they join online: their name, the colour they'd like (-1 for any), and their sandbox slots, +// loadouts, levels and stacking, which their fighter starts with. Plain data, sent as it sits in memory. +struct Profile +{ + char name[PLAYER_NAME_MAX + 1] = {}; + int color = -1; + int weaponSlots = SLOTS_DEFAULT; + int trinketSlots = SLOTS_DEFAULT; + int weaponLoadout[SLOT_MAX] = {}; + int trinketLoadout[SLOT_MAX] = {}; + int weaponSlotLevels[SLOT_MAX] = {}; + int trinketSlotLevels[SLOT_MAX] = {}; + int weaponItemLevels[WEAPON_COUNT] = {}; + int trinketItemLevels[TRINKET_COUNT] = {}; +}; + +enum SessionEventType +{ + EVENT_JOIN, + EVENT_LEAVE, + // A member queues a game in the lobby's sandbox, with its rules, or stops queueing one. + EVENT_QUEUE, + EVENT_UNQUEUE +}; + +// A change to who's playing, at a set tick, so every machine makes it at the same point in the game (and again when +// rolling back over it). From is, for a join, the tick the newcomer's copy of the game starts at. +struct SessionEvent +{ + int tick = 0; + int type = EVENT_JOIN; + int member = -1; + int from = 0; + Profile profile; + Rules rules; +}; + +// The queued game being played, a second game beside the lobby's sandbox (see Session): its state between ticks +// (while it isn't swapped into the game), its bots' map of it, which fighter is this screen's in it and the devices +// that go with that, and the tick it started (each play of it, a fresh one). +struct Arena +{ + Snapshot state; + Bot bot; + int devices[MAX_FIGHTERS] = {}; + int local = 0; + int serial = -1; + int graphSerial = -1; + bool isActive = false; +}; + +// One member's controls by tick, in a ring that keeps the last INPUT_RING ticks. They're needed from start (when the +// member's fighter appears) until until (when it goes). Contiguous is the last tick up to which every tick from start +// on is known, so up to which the member's play is certain. Used is what a tick was +// simulated with when the member's controls had to be guessed, to spot a wrong guess when the real ones come. +struct InputLog +{ + Controls controls[INPUT_RING]; + int ticks[INPUT_RING]; + Controls used[INPUT_RING]; + int usedTicks[INPUT_RING]; + int start = 0; + int until = INT32_MAX; + int contiguous = -1; + int latest = -1; + + InputLog(); + void restart(int from); + bool has(int tick) const; + bool put(int tick, const Controls& controls); + Controls guess(int tick) const; +}; + +// What this screen knows of another member: the last tick of this screen's inputs they've confirmed having, the first +// one they need, whether to send them inputs at all yet, and the tick they last said they were on. +struct SessionPeer +{ + int ack = -1; + int sendFrom = 0; + bool isSending = false; + int remoteTick = 0; +}; + +// Online play as one game on every machine: rollback netcode. Each member's controls go to everyone, a few ticks +// ahead of when they're used (the input delay); a member whose controls for a tick haven't arrived yet is guessed to +// keep doing what they last did, and when the real ones turn out different, the game rewinds to that tick and plays +// forward again (silently). A machine that gets too far ahead of what it knows, or of the others, waits. +// +// The lobby's host decides who comes and goes, and when: a newcomer gets the game's state as of a tick everyone's +// controls are certain for, fast-forwards from there, and appears at a tick a moment ahead, on every machine at once. +// Someone leaving is taken out at the tick after their last known controls. Checksums of certain ticks are compared, +// to catch machines drifting apart. +// +// Lobby members can queue a game in the sandbox (see Match::queue_open). Once it's ready, it starts as a second game, +// the arena, beside the sandbox: both step every tick on every machine, each with their own fighters, the players +// in the arena standing faded in the sandbox meanwhile. This screen shows one of the two (the arena while it plays in +// it, or watches it): that one is swapped into the game around this screen's own work, which otherwise always sees +// the sandbox. +class Session +{ +public: + bool isActive = false; + bool isJoined = false; + bool isDesynced = false; + // Set when what this screen shows changes between the sandbox and the arena, for the camera to jump. + bool isViewChanged = false; + int tick = 0; + int delay = 2; + int rollbacks = 0; + int stalls = 0; + + void host_start(Game& game); + void join_start(Game& game); + void stop(Game& game); + void update(Game& game, Controls local); + bool is_wanted(Game& game); + Profile profile_get(Game& game); + int fighter_get(Game& game, int member); + std::string base_name_get(Game& game, int member); + std::string name_get(Game& game, int member); + void chat_send(Game& game, const std::string& text); + void typing_send(Game& game, bool isTyping); + void kick(Game& game, const std::string& name, const std::string& reason); + int rename_wait_get(); + void queue_send(Game& game, const Rules& rules); + void unqueue_send(Game& game); + bool is_queued(Game& game); + bool view_enter(Game& game); + void view_leave(Game& game); + bool is_arena_on(); + bool is_playing_arena(Game& game); + bool is_watching(); + void watch_start(Game& game); + void watch_stop(Game& game); + void arena_leave(Game& game); + const Arena& arena_get(); + std::string arena_names_get(Game& game); + float ping_get(Game& game, int member); + +private: + std::map logs; + std::map peers; + std::vector events; + std::vector departed; + Snapshot snapshots[SNAPSHOT_RING]; + Snapshot arenaSnapshots[SNAPSHOT_RING]; + int arenaSerials[SNAPSHOT_RING]; + Arena arena; + // Whether this screen watches the arena (not being in it), whether the arena's swapped into the game now, which + // one it showed last (to start afresh on a change), and the chat lines already said for things in the game (which + // replays would otherwise say again). + bool isWatching = false; + bool isSwapped = false; + int shownSerial = -1; + std::set announced; + int snapshotTicks[SNAPSHOT_RING]; + Uint32 checksums[SNAPSHOT_RING]; + std::map remoteChecksums; + int checksumSent = -1; + int ownNext = 0; + int ownStart = 0; + int rollbackTo = -1; + bool isRequested = false; + // Every member's chosen name (cleaned), kept current by their renames, and the colour this screen last asked for. + std::map names; + int sentColor = -1; + // Names can change only every RENAME_WAIT, so a player can't dodge being kicked by renaming: when this screen's can + // next, whether it's told its pending one is waiting, and when each other member's last rename came. + Uint64 nextRename = 0; + bool isRenameWaiting = false; + std::map renamedAt; + + void packets_take(Game& game); + void packet_handle(Game& game, int from, const std::string& bytes, bool isReliable); + void members_watch(Game& game); + void join_grant(Game& game, int member, const Profile& profile); + void leave_decide(Game& game, int member); + void event_add(Game& game, const SessionEvent& event); + void event_send(Game& game, int to, const SessionEvent& event, int inputsFrom, int inputsTo); + void inputs_send(Game& game); + void step(Game& game, bool isLive); + void events_apply(Game& game, int at); + bool is_needed(int member, int at); + int confirmed_get(Game& game); + void checksums_exchange(Game& game); + void local_find(Game& game); + void broadcast(Game& game, const std::string& bytes); + void rename_send(Game& game, int to, bool isQuiet); + void profile_watch(Game& game); + void controls_give(Game& game, bool isArena); + void arena_swap(Game& game); + void arena_start(Game& game, int queuer); + void arena_build(Game& game, const Rules& rules, const int* members, int count); + void arena_after(Game& game); + void arena_end(Game& game); + void arena_locals_find(Game& game); + void arena_save(Game& game, int slot); + void arena_restore(Game& game, int slot); + Uint32 checksum_full(Game& game); + std::string state_full_save(Game& game); + bool state_full_load(Game& game, const std::string& bytes); + void announce(Game& game, const std::string& key, const std::string& text); + void queue_decide(Game& game, int member, bool isOpen, const Rules& rules); +}; diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..9f30115 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,691 @@ +#include "settings.h" +#include "game.h" +#include +#include +#include +#ifdef __EMSCRIPTEN__ +#include +#endif + +constexpr int SETTINGS_LENGTH = 16384; +constexpr int LINE_LENGTH = 64; +constexpr float BINDING_WIDTH = 116; +constexpr float FIELD_WIDTH = 120; +constexpr float SWATCH_GAP = 4; +constexpr float SWATCH_SIZE = 18; +constexpr int SWATCH_COLUMNS = 8; +constexpr float LABEL_WIDTH = 104; +constexpr float NUMBER_WIDTH = 84; +constexpr float PERCENT = 100; +constexpr float ROW_HEIGHT = 18; +constexpr float SCROLL_GAP = 8; +constexpr float SLIDER_WIDTH = 120; +constexpr float TOGGLE_SPACING = 96; +constexpr float VOLUME_STEP = 0.05f; +constexpr const char* DEFAULT_NAME = "Fighter"; +constexpr const char* KEY_BIND = "bind_"; +constexpr const char* KEY_COLLISION = "collision"; +constexpr const char* KEY_COLOR = "color"; +constexpr const char* KEY_FULLSCREEN = "fullscreen"; +constexpr const char* KEY_MUTE = "mute"; +constexpr const char* KEY_NAME = "name"; +constexpr const char* KEY_TRINKET_SLOTS = "trinket_slots"; +constexpr const char* KEY_VOLUME = "volume"; +constexpr const char* KEY_SHAKE = "screenshake"; +constexpr const char* KEY_HUD_AIM = "hud_aim"; +constexpr const char* KEY_INPUT_DELAY = "input_delay"; +constexpr const char* KEY_SERVER_URL = "server_url"; +constexpr const char* KEY_RULES_OPEN = "rules_open"; +constexpr float FOOTER_GAP = 4; +constexpr const char* KEY_HUD_DPS = "hud_dps"; +constexpr const char* KEY_HUD_SPEED = "hud_speed"; +constexpr const char* KEY_VSYNC = "vsync"; +constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots"; +constexpr const char* SETTINGS_FILE = "settings.txt"; +constexpr const char* KEY_SANDBOX_SLOT = "sandbox_slot"; +constexpr const char* KEY_SANDBOX_TRINKET_LEVELS = "sandbox_trinket_levels"; +constexpr const char* KEY_SANDBOX_TRINKETS = "sandbox_trinkets"; +constexpr const char* KEY_SANDBOX_WEAPON_LEVELS = "sandbox_weapon_levels"; +constexpr const char* KEY_SANDBOX_WEAPONS = "sandbox_weapons"; +constexpr const char* KEY_SANDBOX_X = "sandbox_x"; +constexpr const char* KEY_SANDBOX_Y = "sandbox_y"; +constexpr const char* SANDBOX_START_WEAPON = "Sword"; +constexpr float CLEAR_PROBE = 4; +constexpr Uint64 WEB_SAVE_INTERVAL = 5000; + +static std::string file_path_get() +{ + char* folder = SDL_GetPrefPath("", GAME_ID); + std::string path = std::string(folder ? folder : "") + SETTINGS_FILE; + SDL_free(folder); + return path; +} + +// The rules' keys: one per field, item rules prefixed with their kind. +struct RuleField +{ + const char* key; + int Rules::* integer = nullptr; + float Rules::* number = nullptr; + bool Rules::* flag = nullptr; +}; + +constexpr RuleField RULE_FIELDS[] = { + {"rules_mode", &Rules::mode}, + {"rules_players", &Rules::players}, + {"rules_timer", &Rules::timer}, + {"rules_health", nullptr, &Rules::health}, + {"rules_health_multiplier", nullptr, &Rules::healthMultiplier}, + {"rules_bot_fill", nullptr, nullptr, &Rules::isBotFill}, + {"rules_best_of", &Rules::bestOf}, +}; + +struct ItemField +{ + const char* key; + int ItemRules::* integer = nullptr; + bool ItemRules::* flag = nullptr; +}; + +constexpr ItemField ITEM_FIELDS[] = { + {"period", &ItemRules::period}, + {"slots", &ItemRules::slots}, + {"start_level", &ItemRules::startLevel}, + {"timing", &ItemRules::timing}, + {"choice", nullptr, &ItemRules::isChoice}, + {"duplicates", nullptr, &ItemRules::isDuplicates}, + {"level_up", nullptr, &ItemRules::isLevelUp}, + {"mirror", nullptr, &ItemRules::isMirror}, +}; + +constexpr const char* ITEM_PREFIXES[] = {"weapons_", "trinkets_"}; + +// Sets whichever rule a line names, kept in range. +static void rules_read(Rules& rules, const char* key, const char* value) +{ + for (const RuleField& field : RULE_FIELDS) + { + if (SDL_strcmp(key, field.key) != 0) + continue; + if (field.integer) + rules.*field.integer = SDL_atoi(value); + if (field.number) + rules.*field.number = SDL_atof(value); + if (field.flag) + rules.*field.flag = SDL_atoi(value); + } + for (int kind = 0; kind < 2; kind++) + { + ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets; + int prefix = SDL_strlen(ITEM_PREFIXES[kind]); + if (SDL_strncmp(key, ITEM_PREFIXES[kind], prefix) != 0) + continue; + for (const ItemField& field : ITEM_FIELDS) + { + if (SDL_strcmp(key + prefix, field.key) != 0) + continue; + if (field.integer) + items.*field.integer = SDL_atoi(value); + if (field.flag) + items.*field.flag = SDL_atoi(value); + } + items.slots = std::clamp(items.slots, 1, SLOT_MAX); + items.startLevel = std::clamp(items.startLevel, 1, LEVEL_COUNT); + items.timing = std::clamp(items.timing, 0, (int)PICK_PERIOD); + } + // Races are set aside while deathmatch gets online (their mode choice is commented out in the menu's rules), so a + // saved race comes back as a deathmatch. + // rules.mode = std::clamp(rules.mode, (int)MODE_DEATHMATCH, (int)MODE_RACE); + rules.mode = MODE_DEATHMATCH; + rules.players = std::clamp(rules.players, PLAYERS_MIN, PLAYERS_MAX); + rules.bestOf = std::clamp(rules.bestOf, 1, BEST_OF_MAX) | 1; +} + +static int rules_write(Rules& rules, char* text, int size) +{ + int length = 0; + for (const RuleField& field : RULE_FIELDS) + if (field.number) + length += SDL_snprintf(text + length, size - length, "%s=%g\n", field.key, rules.*field.number); + else + length += SDL_snprintf(text + length, size - length, "%s=%d\n", field.key, + field.integer ? rules.*field.integer : (int)(rules.*field.flag)); + for (int kind = 0; kind < 2; kind++) + { + ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets; + for (const ItemField& field : ITEM_FIELDS) + length += SDL_snprintf(text + length, size - length, "%s%s=%d\n", ITEM_PREFIXES[kind], field.key, + field.integer ? items.*field.integer : (int)(items.*field.flag)); + } + return length; +} + +// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save. +static int item_find(bool isTrinket, const char* name) +{ + int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + for (int i = 0; i < count; i++) + if (SDL_strcmp(name, isTrinket ? TRINKET_DEFS[i].name : WEAPON_DEFS[i].name) == 0) + return i; + return -1; +} + +// Reads a comma-separated list: a loadout of item names (empty for an empty slot), each with its copy's level as +// "name:level" (into levels by slot), or item levels as "name:level" (into levels by item). +static void list_read(char* value, bool isTrinket, int* loadout, int* levels) +{ + int count = loadout ? SLOT_MAX : (isTrinket ? TRINKET_COUNT : WEAPON_COUNT); + char* entry = value; + for (int index = 0; entry && index < count; index++) + { + char* rest = SDL_strchr(entry, ','); + if (rest) + *rest++ = '\0'; + char* colon = SDL_strchr(entry, ':'); + if (colon) + *colon++ = '\0'; + int item = item_find(isTrinket, entry); + if (loadout) + loadout[index] = item; + if (levels && colon && item >= 0) + levels[loadout ? index : item] = std::clamp(SDL_atoi(colon) - 1, 0, LEVEL_COUNT - 1); + entry = rest; + } +} + +static void sandbox_read(SandboxSave& sandbox, const char* key, char* value) +{ + bool isSandbox = true; + if (SDL_strcmp(key, KEY_SANDBOX_X) == 0) + sandbox.feet.x = SDL_atof(value); + else if (SDL_strcmp(key, KEY_SANDBOX_Y) == 0) + sandbox.feet.y = SDL_atof(value); + else if (SDL_strcmp(key, KEY_SANDBOX_SLOT) == 0) + sandbox.slot = SDL_atoi(value); + else if (SDL_strcmp(key, KEY_SANDBOX_WEAPONS) == 0) + list_read(value, false, sandbox.weaponLoadout, sandbox.weaponSlotLevels); + else if (SDL_strcmp(key, KEY_SANDBOX_TRINKETS) == 0) + list_read(value, true, sandbox.trinketLoadout, sandbox.trinketSlotLevels); + else if (SDL_strcmp(key, KEY_SANDBOX_WEAPON_LEVELS) == 0) + list_read(value, false, nullptr, sandbox.weaponLevels); + else if (SDL_strcmp(key, KEY_SANDBOX_TRINKET_LEVELS) == 0) + list_read(value, true, nullptr, sandbox.trinketLevels); + else + isSandbox = false; + sandbox.isSaved |= isSandbox; +} + +static int sandbox_write(Fighter& fighter, char* text, int size) +{ + int length = SDL_snprintf(text, size, "%s=%g\n%s=%g\n%s=%d\n", KEY_SANDBOX_X, fighter.center_get().x, KEY_SANDBOX_Y, + fighter.body.box.y + fighter.body.box.h, KEY_SANDBOX_SLOT, fighter.slot); + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int* slotLevels = isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels; + length += + SDL_snprintf(text + length, size - length, "%s=", isTrinket ? KEY_SANDBOX_TRINKETS : KEY_SANDBOX_WEAPONS); + for (int slot = 0; slot < SLOT_MAX; slot++) + { + int item = loadout[slot]; + length += slot > 0 ? SDL_snprintf(text + length, size - length, ",") : 0; + if (item >= 0) + length += + SDL_snprintf(text + length, size - length, "%s:%d", + isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, slotLevels[slot] + 1); + } + length += SDL_snprintf(text + length, size - length, + "\n%s=", isTrinket ? KEY_SANDBOX_TRINKET_LEVELS : KEY_SANDBOX_WEAPON_LEVELS); + int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + for (int item = 0; item < count; item++) + length += SDL_snprintf(text + length, size - length, item > 0 ? ",%s:%d" : "%s:%d", + isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, + (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item] + 1); + length += SDL_snprintf(text + length, size - length, "\n"); + } + return length; +} + +Settings::Settings() +{ + SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); +} + +// Reads the saved settings, keeping the defaults for anything missing. Bindings are "bind_=key,pad". +void Settings::load(Game& game) +{ + std::fill(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), -1); + std::fill(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), -1); + std::fill(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), -1); + std::fill(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), -1); + savedRules = game.match.rules; + char* text = (char*)SDL_LoadFile(file_path_get().c_str(), nullptr); + if (!text) + return; + + char* rest = nullptr; + for (char* line = SDL_strtok_r(text, "\r\n", &rest); line; line = SDL_strtok_r(nullptr, "\r\n", &rest)) + { + char* value = SDL_strchr(line, '='); + if (!value) + continue; + *value++ = '\0'; + int number = SDL_atoi(value); + if (SDL_strcmp(line, KEY_NAME) == 0 && *value) + SDL_strlcpy(name, value, sizeof(name)); + if (SDL_strcmp(line, KEY_SERVER_URL) == 0 && *value) + SDL_strlcpy(serverUrl, value, sizeof(serverUrl)); + if (SDL_strcmp(line, KEY_RULES_OPEN) == 0) + isRulesOpen = number; + if (SDL_strcmp(line, KEY_VOLUME) == 0) + volume = std::clamp(number / PERCENT, 0.0f, 1.0f); + if (SDL_strcmp(line, KEY_MUTE) == 0) + isMuted = number; + if (SDL_strcmp(line, KEY_FULLSCREEN) == 0) + isFullscreen = number; + if (SDL_strcmp(line, KEY_VSYNC) == 0) + isVsync = number; + if (SDL_strcmp(line, KEY_SHAKE) == 0) + isShaking = number; + if (SDL_strcmp(line, KEY_HUD_DPS) == 0) + isDpsShown = number; + if (SDL_strcmp(line, KEY_HUD_SPEED) == 0) + isSpeedShown = number; + if (SDL_strcmp(line, KEY_HUD_AIM) == 0) + isAimShown = number; + if (SDL_strcmp(line, KEY_INPUT_DELAY) == 0) + inputDelay = std::clamp(number, 0, INPUT_DELAY_MAX); + if (SDL_strcmp(line, KEY_COLLISION) == 0) + isCollisionShown = number; + if (SDL_strcmp(line, KEY_COLOR) == 0) + color = std::clamp(number, COLOR_UNASSIGNED, COLOR_COUNT - 1); + if (SDL_strcmp(line, KEY_WEAPON_SLOTS) == 0) + weaponSlots = std::clamp(number, 1, SLOT_MAX); + if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0) + trinketSlots = std::clamp(number, 1, SLOT_MAX); + rules_read(game.match.rules, line, value); + sandbox_read(sandbox, line, value); + for (int i = 0; i < CONTROL_COUNT; i++) + { + char key[LINE_LENGTH]; + SDL_snprintf(key, sizeof(key), "%s%s", KEY_BIND, CONTROL_KEYS[i]); + const char* comma = SDL_strchr(value, ','); + if (SDL_strcmp(line, key) == 0 && comma) + game.input.bindings[i] = {number, SDL_atoi(comma + 1)}; + } + } + SDL_free(text); + savedRules = game.match.rules; +} + +void Settings::save(Game& game) +{ + char text[SETTINGS_LENGTH]; + int length = SDL_snprintf(text, sizeof(text), + "%s=%s\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%" + "d\n%s=%d\n%s=%s\n%s=%d\n", + KEY_NAME, name, KEY_COLOR, color, KEY_VOLUME, (int)std::round(volume * PERCENT), KEY_MUTE, + isMuted, KEY_FULLSCREEN, isFullscreen, KEY_VSYNC, isVsync, KEY_WEAPON_SLOTS, weaponSlots, + KEY_TRINKET_SLOTS, trinketSlots, KEY_COLLISION, isCollisionShown, KEY_SHAKE, isShaking, + KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown, + KEY_INPUT_DELAY, inputDelay, KEY_SERVER_URL, serverUrl, KEY_RULES_OPEN, isRulesOpen); + for (int i = 0; i < CONTROL_COUNT; i++) + { + Binding binding = game.input.bindings[i]; + length += SDL_snprintf(text + length, sizeof(text) - length, "%s%s=%d,%d\n", KEY_BIND, CONTROL_KEYS[i], + binding.key, binding.pad); + } + Match& match = game.match; + length += rules_write(match.rules, text + length, sizeof(text) - length); + length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length, + sizeof(text) - length); + savedRules = match.rules; + SDL_SaveFile(file_path_get().c_str(), text, length); +#ifdef __EMSCRIPTEN__ + // The prefs folder is browser storage (see tools/web_pre.js), written back after every save. + EM_ASM(FS.syncfs(false, function(error){});); +#endif + isDirty = false; +} + +void Settings::apply(Game& game) +{ + fullscreen_apply(game); + game.renderer.vsync_set(isVsync); + game.audio.gain_set(isMuted ? 0 : volume); + if (game.match.state == MATCH_PRACTICE) + { + Fighter& fighter = game.local_get(); + fighter.slots_set(weaponSlots, trinketSlots); + } +} + +// Puts the window in or out of fullscreen to match the setting. The web asks the browser to fill the screen instead, +// since SDL's own fullscreen there snaps the canvas into a corner. +void Settings::fullscreen_apply(Game& game) +{ +#ifdef __EMSCRIPTEN__ + EM_ASM({ + if ($0 && !document.fullscreenElement) + document.documentElement.requestFullscreen().catch(function(error){}); + if (!$0 && document.fullscreenElement) + document.exitFullscreen(); + }, isFullscreen); +#else + SDL_SetWindowFullscreen(game.window, isFullscreen); +#endif +} + +// Flips fullscreen from anywhere, as the F key does, and saves it. +void Settings::fullscreen_toggle(Game& game) +{ + isFullscreen = !isFullscreen; + fullscreen_apply(game); + save(game); +} + +// The Settings tab, scrolling: the sandbox's slot counts and collision display, the window, the audio (volume in 5% +// steps, then mute), and the controls. Changes take effect at once and are saved as soon as the mouse lets go. +void Settings::draw(Game& game, Rect area, Page& page) +{ + Ui& ui = game.ui; + float x = area.x + LABEL_WIDTH; + float y = 0; + Rect row = {area.x, 0, area.w - SCROLL_GAP, WIDGET_HEIGHT}; + // The Default button stays put along the bottom, under the scrolling settings. + Rect footer = {area.x, area.y + area.h - WIDGET_HEIGHT, area.w, WIDGET_HEIGHT}; + if (ui.button_draw(game, footer, "Default")) + defaults_restore(game); + area.h -= WIDGET_HEIGHT + FOOTER_GAP; + ui.page_begin(game, page, area); + + // The sandbox's slots only show in the sandbox. + bool isSandbox = false; + if (game.match.state == MATCH_PRACTICE) + { + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Sandbox"); + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Weapon Slots", {area.x, y}); + isSandbox |= + ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, weaponSlots, {.min = 1, .max = SLOT_MAX}); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Trinket Slots", {area.x, y}); + isSandbox |= + ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, trinketSlots, {.min = 1, .max = SLOT_MAX}); + } + bool isPvp = game.local_get().isPvp; + bool isPvpChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + isPvpChanged = ui.toggle_draw(game, {x, y}, "PvP", isPvp); + + // The fighter changes through its controls, so every machine sees it on the same tick (see Edit). + int device = game.devices[game.local]; + if (isSandbox) + game.input.edit_send(device, + edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); + if (isPvpChanged) + game.input.edit_send(device, edit_encode({.type = EDIT_PVP, .value = isPvp})); + } + + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Window"); + bool isFullscreenChanged = false; + bool isVsyncChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + isFullscreenChanged = ui.toggle_draw(game, {x, y}, "Fullscreen", isFullscreen); + isVsyncChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Vsync", isVsync); + } + bool isShakeChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + isShakeChanged = ui.toggle_draw(game, {x, y}, "Screenshake", isShaking); + if (isFullscreenChanged) + fullscreen_apply(game); + if (isVsyncChanged) + game.renderer.vsync_set(isVsync); + + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay"); + bool isHudChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + isHudChanged |= ui.toggle_draw(game, {x, y}, "DPS Meter", isDpsShown); + isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed Meter", isSpeedShown); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + isHudChanged |= ui.toggle_draw(game, {x, y}, "Aim Preview", isAimShown); + isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Display Collision", isCollisionShown); + } + + // How late this screen's own controls take effect online: more delay, fewer corrections to other players. A change + // takes effect the next time a lobby's game starts. + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Online"); + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Host", {area.x, y}); + isHudChanged |= ui.field_draw(game, {x, y, row.w - LABEL_WIDTH, WIDGET_HEIGHT}, serverUrl, sizeof(serverUrl)); + if (serverUrl[0] == '\0' && !ui.is_active(serverUrl)) + SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Input Delay", {area.x, y}); + isHudChanged |= ui.number_draw(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, inputDelay, + {.min = 0, .max = INPUT_DELAY_MAX, .suffix = " Ticks"}); + } + + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Audio"); + bool isSound = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + char percent[LINE_LENGTH]; + SDL_snprintf(percent, sizeof(percent), "%d%%", (int)std::round(volume * PERCENT)); + ui.label_draw(game, "Volume", {area.x, y}); + isSound = ui.slider_draw(game, {x, y, SLIDER_WIDTH, WIDGET_HEIGHT}, volume, VOLUME_STEP); + ui.label_draw(game, percent, {x + SLIDER_WIDTH + WIDGET_GAP, y}); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + isSound |= ui.toggle_draw(game, {x, y}, "Mute", isMuted); + if (isSound) + game.audio.gain_set(isMuted ? 0 : volume); + + controls_draw(game, page, row); + ui.page_end(game, page); + + isDirty |= isSandbox || isSound || isFullscreenChanged || isVsyncChanged || isShakeChanged || isHudChanged || + game.input.isBindingChanged; + game.input.isBindingChanged = false; + if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT)) + save(game); +} + +// The Controls section: a Default button, then a row per control with its keyboard and gamepad bindings. Clicking a +// binding listens for a new one, and right-clicking (or X on a focused one) clears it. +void Settings::controls_draw(Game& game, Page& page, Rect row) +{ + Ui& ui = game.ui; + Input& input = game.input; + float y = 0; + float keyX = row.x + LABEL_WIDTH; + float padX = keyX + BINDING_WIDTH + WIDGET_GAP; + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Controls"); + if (ui.row_get(page, ROW_HEIGHT, y)) + { + if (ui.button_draw(game, {row.x, y, LABEL_WIDTH - WIDGET_GAP, WIDGET_HEIGHT}, "Default")) + { + input.bindings_reset(); + input.isBindingChanged = true; + } + ui.label_draw(game, "Keyboard", {keyX, y}); + ui.label_draw(game, "Gamepad", {padX, y}); + } + for (int i = 0; i < CONTROL_COUNT; i++) + { + if (!ui.row_get(page, ROW_HEIGHT, y)) + continue; + ui.label_draw(game, CONTROL_NAMES[i], {row.x, y}); + for (int side = 0; side < 2; side++) + { + bool isGamepad = side == 1; + Rect cell = {isGamepad ? padX : keyX, y, BINDING_WIDTH, WIDGET_HEIGHT}; + int& binding = isGamepad ? input.bindings[i].pad : input.bindings[i].key; + bool isCapturing = input.captureControl == i && input.isCaptureGamepad == isGamepad; + char name[LINE_LENGTH]; + if (isCapturing) + SDL_strlcpy(name, "Press...", sizeof(name)); + else + input.binding_name_get(binding, isGamepad, name, sizeof(name)); + if (ui.button_draw(game, cell, name, &binding)) + { + input.captureControl = i; + input.isCaptureGamepad = isGamepad; + } + bool isUnbound = ui.is_focused(&binding) && input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_WEST); + if (isUnbound || (is_point_in_rect(input.mouse, cell) && input.is_mouse_pressed(MOUSE_RIGHT))) + { + binding = -1; + input.isBindingChanged = true; + } + } + } +} + +// The colour this screen's player asks for in a match: their pick, the session's random roll, or none. +int Settings::color_get() +{ + return color; +} + +// The Settings tab's Default: everything on it back as it starts out, except the controls (which have a Default of +// their own) and the name and colour (which are Customize's). +void Settings::defaults_restore(Game& game) +{ + Settings fresh; + volume = fresh.volume; + isMuted = fresh.isMuted; + isFullscreen = fresh.isFullscreen; + isVsync = fresh.isVsync; + isShaking = fresh.isShaking; + isCollisionShown = fresh.isCollisionShown; + isDpsShown = fresh.isDpsShown; + isSpeedShown = fresh.isSpeedShown; + isAimShown = fresh.isAimShown; + inputDelay = fresh.inputDelay; + SDL_strlcpy(serverUrl, fresh.serverUrl, sizeof(serverUrl)); + weaponSlots = fresh.weaponSlots; + trinketSlots = fresh.trinketSlots; + fullscreen_apply(game); + game.renderer.vsync_set(isVsync); + game.audio.gain_set(isMuted ? 0 : volume); + if (game.match.state == MATCH_PRACTICE) + game.input.edit_send(game.devices[game.local], + edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); + isDirty = true; +} + +// The Customize tab: the screen name shown over this screen's fighter, and the colour it asks for in matches (a +// swatch, or Unassigned for whichever is free), with the fighter previewed on the right in it, walking. An emptied +// name goes back to the default. +void Settings::customize_draw(Game& game, Rect area) +{ + Ui& ui = game.ui; + float x = area.x + LABEL_WIDTH; + float y = area.y; + ui.label_draw(game, "Name", {area.x, y}); + isDirty |= ui.field_draw(game, {x, y, FIELD_WIDTH, WIDGET_HEIGHT}, name, sizeof(name)); + y += ROW_HEIGHT; + ui.label_draw(game, "Color", {area.x, y}); + isDirty |= ui.radio_draw(game, {x, y}, "Unassigned", color, COLOR_UNASSIGNED); + y += ROW_HEIGHT; + for (int i = 0; i < COLOR_COUNT; i++) + { + Rect box = {x + i % SWATCH_COLUMNS * (SWATCH_SIZE + SWATCH_GAP), + y + i / SWATCH_COLUMNS * (SWATCH_SIZE + SWATCH_GAP), SWATCH_SIZE, SWATCH_SIZE}; + isDirty |= ui.swatch_draw(game, box, PALETTE[i], color, i); + } + + // The preview: the fighter in the colour picked, its name over it and what the choice means under it. + char caption[LINE_LENGTH]; + if (color == COLOR_UNASSIGNED) + SDL_strlcpy(caption, "Given at match start", sizeof(caption)); + else + SDL_strlcpy(caption, COLOR_NAMES[color], sizeof(caption)); + ui.fighter_preview_draw(game, area, name, color >= 0 ? PALETTE[color] : WHITE, caption); + if (name[0] == '\0' && !ui.is_active(name)) + { + SDL_strlcpy(name, DEFAULT_NAME, sizeof(name)); + isDirty = true; + } + if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !ui.is_active(name)) + save(game); +} + +// Puts the sandbox back as it was saved, once the world and fighter exist: loadouts, levels, the slot held, and where +// the fighter stood (unless that spot is now blocked). With nothing saved yet, the fighter starts with a sword in its +// first slot. +void Settings::restore(Game& game) +{ + Fighter& fighter = game.local_get(); + if (!sandbox.isSaved) + { + fighter.weaponLoadout[0] = item_find(false, SANDBOX_START_WEAPON); + return; + } + std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout); + std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout); + std::copy(std::begin(sandbox.weaponLevels), std::end(sandbox.weaponLevels), fighter.weaponItemLevels); + std::copy(std::begin(sandbox.trinketLevels), std::end(sandbox.trinketLevels), fighter.trinketItemLevels); + // Saves from before copies had their own levels give each copy its item's level. + for (int i = 0; i < SLOT_MAX; i++) + { + int weapon = fighter.weaponLoadout[i]; + int trinket = fighter.trinketLoadout[i]; + int weaponLevel = sandbox.weaponSlotLevels[i]; + int trinketLevel = sandbox.trinketSlotLevels[i]; + fighter.weaponSlotLevels[i] = + weaponLevel >= 0 || weapon < 0 ? std::max(0, weaponLevel) : fighter.weaponItemLevels[weapon]; + fighter.trinketSlotLevels[i] = + trinketLevel >= 0 || trinket < 0 ? std::max(0, trinketLevel) : fighter.trinketItemLevels[trinket]; + } + fighter.slots_set(weaponSlots, trinketSlots); + fighter.slot = std::clamp(sandbox.slot, 0, fighter.weaponSlots - 1); + + // Stood a pixel up, so the feet don't start in the floor. + Rect box = fighter.body.box; + Vec2 centre = {sandbox.feet.x, sandbox.feet.y - box.h / 2 - 1}; + for (float y = -box.h / 2; y <= box.h / 2; y += CLEAR_PROBE) + for (float x = -box.w / 2; x <= box.w / 2; x += CLEAR_PROBE) + if (game.world.is_blocking_at({centre.x + x, centre.y + y})) + return; + fighter.teleport_to(centre); +} + +// Saves once anything has changed and the mouse has let go: rules as soon as they're edited, and the sandbox as a +// match starts from it. +void Settings::update(Game& game) +{ + bool isPractice = game.match.state == MATCH_PRACTICE; + isDirty |= !(game.match.rules == savedRules) || (wasPractice && !isPractice); +#ifdef __EMSCRIPTEN__ + // A browser tab can close at any moment without a quit, so the sandbox is saved every so often there. + Uint64 now = SDL_GetTicks(); + isDirty |= isPractice && now - savedAt >= WEB_SAVE_INTERVAL; + if (isDirty) + savedAt = now; + + // The browser leaves fullscreen on its own (Escape), so the setting follows it. + isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; }); +#endif + wasPractice = isPractice; + if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window)) + save(game); +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..da85ca1 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,86 @@ +#pragma once + +#include "protocol.h" + +#include "common.h" +#include "fighter.h" +#include "match.h" + +struct Game; +struct Page; + +constexpr int NAME_LENGTH = 16; + +constexpr int INPUT_DELAY_DEFAULT = 2; +constexpr int INPUT_DELAY_MAX = 4; + +// The sandbox as it was left: where the fighter stood (its feet), the slot it held, and its loadouts and levels. +struct SandboxSave +{ + Vec2 feet; + int slot = 0; + int trinketLevels[TRINKET_COUNT] = {}; + int trinketLoadout[SLOT_MAX]; + int trinketSlotLevels[SLOT_MAX]; + int weaponLevels[WEAPON_COUNT] = {}; + int weaponLoadout[SLOT_MAX]; + int weaponSlotLevels[SLOT_MAX]; + bool isSaved = false; +}; + +// The player's preferences, kept as key=value lines in the user's prefs folder: saved after every change and read +// back at startup. Apply pushes them all to the window, renderer, audio and the sandbox fighter's slots. The menu's +// Settings tab edits them (and the control bindings, which live in Input), and Customize the name and colour. Colour +// is a palette index, unassigned (given one at the match) or random (session colour, rolled once per run). The match +// rules and the sandbox (see SandboxSave) are kept too: rules as soon as they change, the sandbox when the game quits +// or a match starts (and every few seconds on the web, where tabs close without quitting), restored once at startup +// (restore). Update saves anything changed once the mouse lets go. +class Settings +{ +public: + char name[NAME_LENGTH + 1] = "Fighter"; + float volume = 1; + int color = COLOR_UNASSIGNED; + int trinketSlots = 2; + int weaponSlots = 2; + bool isAimShown = true; + // Online, how many ticks late this screen's controls take effect (see Session). + int inputDelay = INPUT_DELAY_DEFAULT; + bool isCollisionShown = false; + bool isDpsShown = true; + bool isFullscreen = false; + bool isMuted = false; + bool isShaking = true; + bool isSpeedShown = true; + bool isVsync = true; + // The server online play goes through (see protocol.h), and whether the Play tab's rules are unfolded. + char serverUrl[SERVER_URL_LENGTH] = {}; + bool isRulesOpen = true; + + Settings(); + void load(Game& game); + void save(Game& game); + void apply(Game& game); + void fullscreen_apply(Game& game); + void fullscreen_toggle(Game& game); + void draw(Game& game, Rect area, Page& page); + void customize_draw(Game& game, Rect area); + void defaults_restore(Game& game); + int color_get(); + // Marks the settings for saving (as the menu does), for changes made elsewhere, like chat commands. + void changed() + { + isDirty = true; + } + void restore(Game& game); + void update(Game& game); + +private: + SandboxSave sandbox; + Rules savedRules; + Uint64 savedAt = 0; + bool isDirty = false; + bool wasPractice = true; + + void controls_draw(Game& game, Page& page, Rect area); +}; diff --git a/src/sprites.cpp b/src/sprites.cpp new file mode 100644 index 0000000..c3e5c1a --- /dev/null +++ b/src/sprites.cpp @@ -0,0 +1,29 @@ +#include "sprites.h" +#include "game.h" + +void Sprites::init(Game& game) +{ + Renderer& renderer = game.renderer; + dummy.load(renderer, "dummy"); + effects.load(renderer, "effects"); + icons.load(renderer, "weapon_icons"); + iconsSmall.load(renderer, "weapon_icons_small"); + iconsTiny.load(renderer, "weapon_icons_tiny"); + player.load(renderer, "player"); + tiles.load(renderer, "tiles"); + trinkets.load(renderer, "trinkets"); + trinketsSmall.load(renderer, "trinkets_small"); + trinketsTiny.load(renderer, "trinkets_tiny"); + modes.load(renderer, "modes"); + ui.load(renderer, "ui"); + weapons.load(renderer, "weapons"); + for (Animator& animator : fighters) + animator.art = &player; +} + +// Each tick, every fighter's animator plays whatever its fighter is doing. +void Sprites::update(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + game.fighters[i].animator_update(fighters[i]); +} diff --git a/src/sprites.h b/src/sprites.h new file mode 100644 index 0000000..f1f0eea --- /dev/null +++ b/src/sprites.h @@ -0,0 +1,32 @@ +#pragma once + +#include "anm2.h" +#include "fighter.h" + +struct Game; + +// The game's art: one anm2 per category in resources/gfx, and each fighter's animator. Presentation only, so none of +// it is saved in snapshots. +class Sprites +{ +public: + Anm2 dummy; + Anm2 effects; + // Weapon and trinket icons at full (24 pixel), small (16) and tiny (8) sizes. + Anm2 icons; + Anm2 iconsSmall; + Anm2 iconsTiny; + Anm2 player; + Anm2 tiles; + Anm2 trinkets; + Anm2 trinketsSmall; + Anm2 trinketsTiny; + // A game mode's icon, by its name (Deathmatch, Race). + Anm2 modes; + Anm2 ui; + Anm2 weapons; + Animator fighters[MAX_FIGHTERS]; + + void init(Game& game); + void update(Game& game); +}; diff --git a/src/state.cpp b/src/state.cpp new file mode 100644 index 0000000..2fb803f --- /dev/null +++ b/src/state.cpp @@ -0,0 +1,162 @@ +#include "state.h" +#include "game.h" +#include +#include + +constexpr Uint32 STATE_MAGIC = 0x53414253; // "SBAS" + +// Everything the bytes depend on laying out the same: if any of these differ between two builds, so might the +// layouts, and the state can't pass between them. +static Uint32 layout_get() +{ + Uint32 hash = 2166136261u; + for (Uint32 size : {sizeof(Fighter), sizeof(Attack), sizeof(Match), sizeof(Dummy), sizeof(Rect), sizeof(Ramp), + sizeof(Tile), sizeof(Rng), sizeof(BotState), sizeof(Echo), sizeof(Controls), sizeof(Vec2)}) + hash = (hash ^ size) * 16777619u; + return hash ^ PROTOCOL_VERSION; +} + +struct Writer +{ + std::string bytes; + + template void raw(const T* items, Uint32 count) + { + static_assert(std::is_trivially_copyable_v); + bytes.append(reinterpret_cast(items), sizeof(T) * count); + } + template void one(const T& item) + { + raw(&item, 1); + } + template void list(const std::vector& items) + { + one((Uint32)items.size()); + raw(items.data(), items.size()); + } +}; + +struct Reader +{ + const std::string& bytes; + size_t at = 0; + bool isBad = false; + + template void raw(T* items, Uint32 count) + { + static_assert(std::is_trivially_copyable_v); + size_t size = sizeof(T) * count; + if (isBad || at + size > bytes.size()) + { + isBad = true; + return; + } + std::memcpy(static_cast(items), bytes.data() + at, size); + at += size; + } + template void one(T& item) + { + raw(&item, 1); + } + template void list(std::vector& items) + { + Uint32 count = 0; + one(count); + if (isBad || count > (bytes.size() - at) / sizeof(T)) + { + isBad = true; + return; + } + items.resize(count); + raw(items.data(), count); + } +}; + +// Walks the bytes as a Reader would without writing anything, to check they're whole before loading. +struct Checker +{ + const std::string& bytes; + size_t at = 0; + bool isBad = false; + + template void raw(T*, Uint32 count) + { + size_t size = sizeof(T) * count; + isBad |= at + size > bytes.size(); + at = isBad ? at : at + size; + } + template void one(T& item) + { + raw(&item, 1); + } + template void list(std::vector&) + { + Uint32 count = 0; + if (at + sizeof(count) > bytes.size()) + { + isBad = true; + return; + } + std::memcpy(&count, bytes.data() + at, sizeof(count)); + at += sizeof(count); + isBad |= count > (bytes.size() - at) / sizeof(T); + T* none = nullptr; + if (!isBad) + raw(none, count); + } +}; + +// Both directions walk the same fields in the same order. Weapons' pending attacks aren't among them: they only exist +// inside a tick, and the state is taken between ticks. +template static void state_walk(Stream& stream, GameRef& game) +{ + stream.one(game.fighterCount); + stream.raw(game.fighters, MAX_FIGHTERS); + stream.one(game.match); + stream.one(game.rng); + stream.raw(game.bot.states, MAX_FIGHTERS); + stream.one(game.weapons.orbitClock); + stream.list(game.weapons.attacks); + World& world = game.world; + stream.list(world.dummies); + stream.list(world.obstacles); + stream.list(world.ramps); + stream.list(world.tiles); + stream.raw(world.spawns, SPAWN_COUNT); + stream.one(world.height); + stream.one(world.width); +} + +std::string state_save(Game& game) +{ + Writer writer; + writer.one(STATE_MAGIC); + writer.one(layout_get()); + state_walk(writer, game); + return writer.bytes; +} + +// Loads the state if it's whole and from a build that lays things out the same, then rebuilds what's derived from it. +// A refused state leaves the game as it was. +bool state_load(Game& game, const std::string& bytes) +{ + Reader check = {bytes}; + Uint32 magic = 0; + Uint32 layout = 0; + check.one(magic); + check.one(layout); + if (check.isBad || magic != STATE_MAGIC || layout != layout_get()) + return false; + + // Checked whole first, so a truncated state can't leave the game half loaded. + Checker checker = {bytes, check.at}; + state_walk(checker, game); + if (checker.isBad || checker.at != bytes.size()) + return false; + Reader reader = {bytes}; + reader.one(magic); + reader.one(layout); + state_walk(reader, game); + game.bot.graph_build(game.world); + return true; +} diff --git a/src/state.h b/src/state.h new file mode 100644 index 0000000..7c6bd3b --- /dev/null +++ b/src/state.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +struct Game; + +// The whole simulation as bytes, to hand a player joining a game already running. The game state is plain data that +// lays out the same on every platform (no pointers or heap inside the structs; see Attack and FixedList), so most of it +// goes as it sits in memory, and the few lists as a count and their items. It starts with a layout signature (the sizes +// of everything), so a build that lays things out differently refuses it rather than loading garbage. The menu and +// everything else this screen alone sees isn't part of it, nor is the bots' map of the world, rebuilt on loading. +std::string state_save(Game& game); +bool state_load(Game& game, const std::string& bytes); diff --git a/src/text.cpp b/src/text.cpp new file mode 100644 index 0000000..df1bee9 --- /dev/null +++ b/src/text.cpp @@ -0,0 +1,156 @@ +#include "text.h" +#include +#include +#include +#include + +constexpr const char* FONT_FOLDER = "font/"; +constexpr const char* FONT_NAMES[FONT_COUNT] = {"tiny", "small", "big", "huge"}; +constexpr Vec2 OUTLINE_OFFSETS[] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + +void Text::init(Renderer& renderer) +{ + for (int font = 0; font < FONT_COUNT; font++) + load(renderer, (Font)font, FONT_NAMES[font]); +} + +static float number_get(simdjson::dom::object source, const char* key, float fallback) +{ + double value; + return source[key].get(value) ? fallback : (float)value; +} + +// Reads a font's .fnt (JSON: its image, the scale it's drawn for, line height, outline and each character's cell) and +// puts its image in the atlas. +void Text::load(Renderer& renderer, Font font, const char* name) +{ + std::string path = path_get(FONT_FOLDER) + name + ".fnt"; + size_t size = 0; + char* text = (char*)SDL_LoadFile(path.c_str(), &size); + if (!text) + { + SDL_Log("Failed to load %s: %s", path.c_str(), SDL_GetError()); + return; + } + simdjson::padded_string json(text, size); + SDL_free(text); + simdjson::dom::parser parser; + simdjson::dom::object document; + std::string_view image; + if (parser.parse(json).get(document) || document["image"].get(image)) + { + SDL_Log("Failed to parse %s", path.c_str()); + return; + } + + FontAtlas& atlas = fonts[font]; + atlas.lineHeight = number_get(document, "lineHeight", LINE_HEIGHT); + atlas.outline = number_get(document, "outline", 1); + atlas.scale = number_get(document, "scale", 0); + Rect sheet = renderer.image_load(FONT_FOLDER + std::string(image)); + simdjson::dom::object glyphs; + if (document["glyphs"].get(glyphs)) + return; + for (auto [key, value] : glyphs) + { + simdjson::dom::object fields; + if (key.size() != 1 || value.get(fields)) + continue; + int index = key[0] - GLYPH_FIRST; + if (index < 0 || index >= GLYPH_COUNT) + continue; + atlas.glyphs[index] = { + .region = {sheet.x + number_get(fields, "x", 0), sheet.y + number_get(fields, "y", 0), + number_get(fields, "w", 0), number_get(fields, "h", 0)}, + .offset = {number_get(fields, "dx", 0), number_get(fields, "dy", 0)}, + .advance = number_get(fields, "advance", 0), + }; + } +} + +// The family font drawn for text at the scale asked for (of the font asked for): the largest one whose size divides +// it, leaving scale at what's left over. Fonts outside the family come back as they are. +Font Text::resolve(Font font, float& scale) +{ + if (fonts[font].scale <= 0) + return font; + float wanted = fonts[font].scale * scale; + Font best = font; + float bestScale = 0; + for (int candidate = 0; candidate < FONT_COUNT; candidate++) + { + float size = fonts[candidate].scale; + float left = size > 0 ? wanted / size : 0; + if (left >= 1 && std::abs(left - std::round(left)) < 1e-3f && size > bestScale) + { + best = (Font)candidate; + bestScale = size; + } + } + scale = bestScale > 0 ? wanted / bestScale : scale; + return best; +} + +// A line's height in the font at a scale (see resolve). +float Text::line_height_get(Font font, float scale) +{ + return fonts[resolve(font, scale)].lineHeight * scale; +} + +// Draws the string with its top-left at draw.position, scaled by draw.scale.x (see resolve), with the font's black +// outline. +void Text::draw(Renderer& renderer, const char* string, Draw draw, Font font) +{ + float scale = draw.scale.x; + FontAtlas& atlas = fonts[resolve(font, scale)]; + + for (size_t pass = 0; pass <= std::size(OUTLINE_OFFSETS); pass++) + { + bool isOutline = pass < std::size(OUTLINE_OFFSETS); + Vec2 start = draw.position + (isOutline ? OUTLINE_OFFSETS[pass] * atlas.outline * scale : Vec2{}); + Vec2 pen = start; + Color color = isOutline ? Color{0, 0, 0, draw.color.a} : draw.color; + + for (const char* character = string; *character; character++) + { + if (*character == '\n') + { + pen = {start.x, pen.y + atlas.lineHeight * scale}; + continue; + } + + int index = *character - GLYPH_FIRST; + if (index < 0 || index >= GLYPH_COUNT) + continue; + + Glyph& glyph = atlas.glyphs[index]; + renderer.draw({.color = color, + .region = glyph.region, + .position = pen + glyph.offset * scale, + .scale = {scale, scale}}); + pen.x += glyph.advance * scale; + } + } +} + +// Size of a possibly multi-line string drawn in the font at a scale. +Vec2 Text::size_get(const char* string, Font font, float scale) +{ + FontAtlas& atlas = fonts[resolve(font, scale)]; + float lineWidth = 0; + Vec2 size = {0, atlas.lineHeight}; + + for (const char* character = string; *character; character++) + { + int index = *character - GLYPH_FIRST; + if (*character == '\n') + { + lineWidth = 0; + size.y += atlas.lineHeight; + } + else if (index >= 0 && index < GLYPH_COUNT) + lineWidth += atlas.glyphs[index].advance; + size.x = std::max(size.x, lineWidth); + } + return size * scale; +} diff --git a/src/text.h b/src/text.h new file mode 100644 index 0000000..88bcc23 --- /dev/null +++ b/src/text.h @@ -0,0 +1,56 @@ +#pragma once + +#include "renderer.h" + +constexpr int GLYPH_COUNT = 95; +constexpr int GLYPH_FIRST = 32; +// The small font's line height (small.fnt's), which the HUD is laid out by. +constexpr float LINE_HEIGHT = 12; + +// The fonts, each an atlas with a .fnt file (see tools/fonts_export.py): tiny for squeezing text in, small for most +// text, and big and huge for text drawn two and three times the size. +enum Font +{ + FONT_TINY, + FONT_SMALL, + FONT_BIG, + FONT_HUGE, + FONT_COUNT +}; + +// Where a character's cell is in the atlas, where it goes relative to the pen at the line's top, and how far the pen +// then moves. +struct Glyph +{ + Rect region; + Vec2 offset; + float advance = 0; +}; + +// A loaded font: its glyphs, line height and outline thickness, and the size of text it's drawn for (0 for a font +// only used by name). +struct FontAtlas +{ + Glyph glyphs[GLYPH_COUNT]; + float lineHeight = 0; + float outline = 1; + float scale = 0; +}; + +// Loads the fonts from resources/font and draws outlined strings. Small, big and huge make one family by the size +// they're drawn for: asking for one of them at a scale draws the family's font for that size (at whatever scale is +// left over), so text twice the small size uses big pixel for pixel, and four times uses big doubled. +class Text +{ +public: + void init(Renderer& renderer); + void draw(Renderer& renderer, const char* string, Draw draw, Font font = FONT_SMALL); + Vec2 size_get(const char* string, Font font = FONT_SMALL, float scale = 1); + float line_height_get(Font font = FONT_SMALL, float scale = 1); + +private: + FontAtlas fonts[FONT_COUNT]; + + void load(Renderer& renderer, Font font, const char* name); + Font resolve(Font font, float& scale); +}; diff --git a/src/trinket.h b/src/trinket.h new file mode 100644 index 0000000..7f597e6 --- /dev/null +++ b/src/trinket.h @@ -0,0 +1,164 @@ +#pragma once + +#include "weapon.h" + +struct Game; + +enum TrinketId +{ + TRINKET_GLOVES, + TRINKET_DRINK, + TRINKET_BOXING, + TRINKET_ACCELERATOR, + TRINKET_ENLARGER, + TRINKET_DIAZEPAM, + TRINKET_SPLITTER, + TRINKET_DOUBLER, + TRINKET_DUAL, + TRINKET_RICOCHET, + TRINKET_TESLA, + TRINKET_RECOIL, + TRINKET_ABSORBER, + TRINKET_SHRINKER, + TRINKET_EMBIGGENER, + TRINKET_PEBBLE, + TRINKET_BOOTS, + TRINKET_SPEEDOMETER, + TRINKET_WINGS, + TRINKET_FEATHER, + TRINKET_WEIGHTS, + TRINKET_CLIMBING, + TRINKET_COUNT +}; + +// A trinket's text and its effect at each level, and a quote for its tooltip. Values are what the game reads while +// it's equipped. When copies stack (see Fighter::trinket_get), their values add up to at most the cap (0 for none). +struct TrinketDef +{ + const char* name; + const char* blurb; + const char* effects[LEVEL_COUNT]; + float values[LEVEL_COUNT]; + const char* flavor = ""; + float cap = 0; +}; + +inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { + {"Gloves", + "Attack more often.", + {"1.15x Attack Rate", "1.3x Attack Rate", "1.45x Attack Rate"}, + {1.15f, 1.3f, 1.45f}, + "A fiery fist o' pain!"}, + {"Energy Drink", + "Charge attacks faster.", + {"1.15x Charge Speed", "1.3x Charge Speed", "1.45x Charge Speed"}, + {1.15f, 1.3f, 1.45f}, + "10000x times the daily recommended\nintake of caffeine."}, + {"Boxing Glove", + "Your hits knock fighters back\nharder.", + {"1.15x Knockback Dealt", "1.3x Knockback Dealt", "1.45x Knockback Dealt"}, + {1.15f, 1.3f, 1.45f}, + "Get ready to study hard, Mac.\nThis is call-"}, + {"Accelerator", + "Projectiles fly faster.", + {"1.2x Projectile Speed", "1.4x Projectile Speed", "1.6x Projectile Speed"}, + {1.2f, 1.4f, 1.6f}, + "Gotta go fast!"}, + {"Enlarger", + "Weapons and projectiles grow.", + {"1.2x Attack Size", "1.4x Attack Size", "1.6x Attack Size"}, + {1.2f, 1.4f, 1.6f}, + "A noble spirit embiggens the\nsmallest projectile."}, + {"Diazepam", + "Steadies your aim: every attack,\nvolley and Splitter copy spreads less.", + {"33% Less Spread", "66% Less Spread", "No Spread"}, + {0.33f, 0.66f, 1}, + "Diazepam?\nPsychotropic drug?\nStops hands from trembling?\nQuestions?", + 1}, + {"Splitter", + "Every attack splits into smaller\ncopies (3/4 size), each dealing\npart of its damage.", + {"2 Copies, 60% Damage Each", "3 Copies, 60% Damage Each", "4 Copies, 60% Damage Each"}, + {2, 3, 4}, + "Quantum entanglement"}, + {"Doubler", + "Every attack repeats a moment\nlater for less damage.", + {"10% Damage Repeat", "25% Damage Repeat", "33% Damage Repeat"}, + {0.1f, 0.25f, 1 / 3.0f}, + "Stud Multiplier"}, + {"Dual Wield", + "Your previous weapon fires from\nyour off hand too, for a cut of\nits damage.", + {"10% Off-Hand Damage", "25% Off-Hand Damage", "33% Off-Hand Damage"}, + {0.1f, 0.25f, 1 / 3.0f}, + "Guns akimbo!"}, + {"Ricochet", + "Pellets, arrows, rockets and beams\nricochet off walls, and each\nricochet adds 0.5x their damage.\nThe flail " + "ball " + "gets bouncier.", + {"1 Ricochet", "2 Ricochets", "3 Ricochets"}, + {1, 2, 3}, + "RICOCHET 2 BABY!"}, + {"Tesla Coil", + "Lightning chains between your\nweapons out in the world, shocking\nanything it touches.", + {"4 Damage Shocks\n120 Arc Range", "7 Damage Shocks\n170 Arc Range", "10 Damage Shocks\n220 Arc Range"}, + {4, 7, 10}, + "Okay this is just Technology Zero."}, + {"Recoil Coil", + "Attacks push you away from your\naim, harder the more damage\nthey deal. Rapid fire adds up.", + {"12 Recoil Power", "18 Recoil Power", "24 Recoil Power"}, + {12, 18, 24}, + "The forgotten sibling of Gravity\nand Speed..."}, + {"Shock Absorber", + "Your own weapons move you around\nless (recoil, kickback, lunges,\nrebounds, pulls and blasts), and\nyou take " + "less knockback.", + {"25% Less Weapon Movement\n17% Less Knockback Taken", "50% Less Weapon Movement\n33% Less Knockback Taken", + "75% Less Weapon Movement\n50% Less Knockback Taken"}, + {0.75f, 0.5f, 0.25f}, + "The mechanical kind, not the\nelectrical kind."}, + {"Shrinker", + "Makes you smaller.", + {"0.9x Size", "0.8x Size", "0.7x Size"}, + {0.9f, 0.8f, 0.7f}, + "I can deny it NO LONGER...\nI am small."}, + {"Embiggener", + "Makes you bigger, and you hit a\nlittle harder to make up for it.", + {"1.33x Size\n1.11x Damage", "1.67x Size\n1.22x Damage", "2x Size\n1.33x Damage"}, + {4 / 3.0f, 5 / 3.0f, 2}, + "I'm a big boy now!"}, + {"Pebble Helmet", + "Bumping into fighters hurts them,\nmore the faster you're going.", + {"6 Contact Damage", "10 Contact Damage", "14 Contact Damage"}, + {6, 10, 14}, + "Stealthy and rocky."}, + {"Boots", + "Run faster and get up to speed\nsooner.", + {"1.3x Run Speed & Acceleration", "1.65x Run Speed & Acceleration", "2x Run Speed & Acceleration"}, + {1.3f, 1.65f, 2}, + "THE BOOT!"}, + {"Speedometer", + "Run faster, and get even more\ndamage out of speed: yours, or\nyour shots' when you fire on the\nmove.", + {"1.05x Run Speed\n+5% More Damage per 100 Speed", "1.1x Run Speed\n+10% More Damage per 100 Speed", + "1.15x Run Speed\n+15% More Damage per 100 Speed"}, + {0.05f, 0.1f, 0.15f}, + "When this baby hits 88 miles\nper hour..."}, + {"Wings", + "Jump again in the air to dash\ntoward the cursor.", + {"250 Dash Speed", "300 Dash Speed", "350 Dash Speed"}, + {250, 300, 350}, + "Probably given to you from\nEnergy Drink."}, + {"Feather", + "Hold Up in the air to float.", + {"0.6x Gravity Holding Up", "0.45x Gravity Holding Up", "0.3x Gravity Holding Up"}, + {0.6f, 0.45f, 0.3f}, + "Cape not included."}, + {"Iron Weights", + "Fall even faster holding Down.\nLanding while holding Down slams\nthe ground, harder the faster you\nhit it.", + {"1.1x Fast Fall\n20 Damage Slam", "1.2x Fast Fall\n35 Damage Slam", "1.3x Fast Fall\n50 Damage Slam"}, + {1.1f, 1.2f, 1.3f}, + "It's just water weight!"}, + {"Climb Claw", + "Hold into a wall to slide down it.\nLevel 2: press away to kick off.\nLevel 3: jump off walls freely.", + {"Wall Cling", "Wall Cling, Wall Kick", "Wall Cling, Kick, Jump"}, + {1, 2, 3}, + "Stolen from a wooden chest on\nthe surface.", + 3}, +}; diff --git a/src/ui.cpp b/src/ui.cpp new file mode 100644 index 0000000..802bd43 --- /dev/null +++ b/src/ui.cpp @@ -0,0 +1,2777 @@ +#include "ui.h" +#include "game.h" +#include +#include + +constexpr int CHAMFER = 2; +constexpr float BANNER_SCALE = 4; +constexpr float BANNER_Y = 110; +constexpr float BAR_GAP = 2; +constexpr float LOADOUT_FADE = 0.25f; +constexpr float LOADOUT_GAP = 4; +constexpr float LOADOUT_DIVIDER = 2; +constexpr float ICON_SIZE = 24; +constexpr float RIVAL_BAR_MIN = 16; +constexpr float RIVAL_DIVIDER = 1; +constexpr float RIVAL_GAP = 4; +constexpr float RIVAL_ICON_GAP = 2; +constexpr float RIVAL_OUT_ALPHA = 0.25f; +constexpr float SPECTATE_GAP = 4; +constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK}; +constexpr float HUD_IDLE_ALPHA = 0.5f; +constexpr float HUD_HOVER_FADE = 0.15f; +constexpr float RIVAL_PADDING = 2; +constexpr int RIVAL_COLUMNS_MAX = 3; +constexpr int RIVAL_FULL_SLOTS = 4; +constexpr float COOLDOWN_SLOTS = 2; +constexpr float MINI_ICON_SCALE = 0.5f; +constexpr float MINI_SLOT = 16; +constexpr float MINI_STEP = 18; +constexpr float PICK_PADDING = 4; +constexpr float PLAYER_PANEL_HEIGHT = 56; +constexpr float READY_BUTTON_HEIGHT = 18; +constexpr float READY_BUTTON_WIDTH = 80; +constexpr float READY_GAP = 2; +constexpr float TIP_GAP = 4; +constexpr float COUNTDOWN_Y = 40; +constexpr float COUNTDOWN_DIM = 0.6f; +constexpr float CHEVRON_INSET = 3; +constexpr float CHEVRON_STEP = 3; +constexpr float CHARGE_BAR_HEIGHT = 8; +constexpr float CHARGE_BAR_WIDTH = 20; +constexpr float DPS_SCALE = 2; +constexpr float FIGHT_BANNER_TIME = 0.6f; +constexpr float HEALTH_BAR_HEIGHT = 7; +constexpr float HEALTH_BAR_WIDTH = 28; +constexpr float ROUND_SCALE = 3; +constexpr float ASIDE_LABEL_SCALE = 2; +constexpr float ASIDE_PADDING = 4; +constexpr float SCORE_SCALE = 2; +constexpr float SCORE_HOTBAR_GAP = 8; +constexpr float STATUS_BAR_HEIGHT = 12; +constexpr float STATUS_BAR_WIDTH = 80; +constexpr float STATUS_GAP = 6; +constexpr float PANEL_BORDER = 2; +constexpr float HOVER_BORDER = 1; +constexpr float HOVER_GAP = 1; +constexpr float TARGET_GAP = 1; +constexpr float TARGET_HEIGHT = 2; +constexpr float TARGET_INSET = 4; +constexpr float CARET_BLINK = 0.5f; +constexpr float COOLDOWN_BAR_HEIGHT = 7; +constexpr float COOLDOWN_FADE_IN = 0.1f; +constexpr float COOLDOWN_FADE_OUT = 0.25f; +constexpr float COOLDOWN_GAP = 4; +constexpr float COOLDOWN_SPACING = 26; +constexpr float COOLDOWN_COMPACT_SPACING = 14; +constexpr float COOLDOWN_SLIDE = 12; +constexpr int COOLDOWN_FULL_ROWS = 5; +constexpr float COOLDOWN_VISIBLE = 0.25f; +constexpr float OFFSCREEN_ARROW_SCALE = 2; +constexpr float OFFSCREEN_MARGIN = 8; +constexpr float OFFSCREEN_NAME_GAP = 10; +constexpr float CARET_HEIGHT = 9; +constexpr float CHECK_INSET = 4; +constexpr float DISABLED_ALPHA = 0.4f; +constexpr float RADIO_INSET = 4; +constexpr float SCROLL_STEP = 24; +constexpr float SWATCH_INSET = 1; +constexpr float SWATCH_INSET_ON = 3; +constexpr float FOCUS_ALIGN_WEIGHT = 0.01f; +constexpr float FOCUS_ARM = 4; +constexpr float FOCUS_OUTSET = 2; +constexpr float FOCUS_ROW_SLACK = 4; +constexpr float SCROLL_WIDTH = 4; +constexpr float SEPARATOR_GAP = 4; +constexpr float FOLD_ARROW = 3; +constexpr float PREVIEW_CAPTION_GAP = 8; +constexpr float PREVIEW_FEET = 120; +constexpr float PREVIEW_NAME_Y = 16; +constexpr float PREVIEW_SCALE = 3; +constexpr const char PREVIEW_ANIMATION[] = "Walk Right"; +constexpr float SUMMARY_ICON_GAP = 4; +constexpr float NAME_PING_GAP = 3; +// A queued game's area: its fill and edge, the queuer's icon over it (how high it floats and bobs, and how fast), and +// the gap to the lines under it. +constexpr float QUEUE_FILL_ALPHA = 0.12f; +constexpr float QUEUE_EDGE_ALPHA = 0.6f; +constexpr float QUEUE_ICON_RISE = 44; +constexpr float QUEUE_ICON_BOB = 2; +constexpr float QUEUE_ICON_SPEED = 3; +constexpr float QUEUE_TEXT_GAP = 3; +constexpr float FIELD_INSET = 4; +constexpr float SLIDER_INSET = 3; +constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2; +constexpr float HINT_FADE = 0.5f; +constexpr float TOOLTIP_OFFSET = 12; +constexpr float TOOLTIP_PADDING = 6; +constexpr float PREVIEW_LINE_MAX = 200; +constexpr float TABLE_GAP = 8; +constexpr float TABLE_SPACE = 4; +constexpr const char* TABLE_HEADERS[] = {"Attack", "Charged"}; +constexpr float TRACK_DOT = 6; +constexpr float TRACK_HEIGHT = 2; +constexpr float TRACK_WIDTH = 200; +constexpr float SECONDS_PER_MINUTE = 60; +constexpr Vec2 LABEL_OFFSET = {4, 2}; + +constexpr const char* TIPS[] = { + "Big hits launch fighters, and slamming into walls while launched hurts.", + "Swing the Hammer into a wall to rebound off it, even mid-launch.", + "Ranged shots lose damage the farther they fly. Get close.", + "Explosions hit hardest at their centre.", + "Your own explosions push you but never hurt you. Rocket jump!", + "Arrows stuck in walls are solid to you. Stand on them.", + "Charged boomerangs are platforms only you can ride.", + "Standing in your own flames brakes you, even mid-launch.", + "Shots carry your momentum. Fire while charging forward for extra damage.", + "Every hit is stronger the faster you're moving.", + "Let go of a charge early for a normal attack that hits harder.", + "Mines need a moment to arm before they'll go off.", + "The Whip's charged blast only lands at the very tip.", + "A charged grapple hit drags you straight to your target.", + "Platforms block shots. Use them as cover.", + "With Iron Weights, hold Down to slam the ground. Faster falls hit harder.", + "Ricochet shots add damage with every bounce.", + "Your charge carries over when you swap weapons.", + "Charge recalls your boomerangs once they're all out.", + "Attack or Charge reels a thrown flail ball back in.", + "Uzi and Blaster hits barely push. Melee and explosions send fighters flying.", + "Rapid small hits can't juggle you. Knockback tops up, it doesn't stack.", + "Crack the Whip against a wall to be pulled toward it.", + "Recoil is movement. Fire away from where you want to go.", + "Splitter copies fan wider with each level.", + "Dual Wield fires your previous weapon from your off hand too.", + "Tesla Coil chains lightning between your weapons out in the world.", + "Each round levels up every item, and health rises with it.", + "With the Feather, hold Up in the air to float.", + "The Lance's charged poke lunges you forward.", + "In a race, dithered pits send you back to the last solid ground you stood on.", + "Race blocks grow back a few seconds after they break. Don't dawdle.", + "Hits in a race don't hurt, but they still knock. Push rivals into pits.", + "Shotgun recoil can double as a jump.", +}; + +static CountdownInfo countdown_info_get(Game& game, bool isPad); + +void Ui::init(Game& game) +{ + for (int i = 0; i < MAX_FIGHTERS; i++) + { + SDL_snprintf(botNames[i], sizeof(botNames[i]), "Bot %d", i + 1); + SDL_snprintf(playerNames[i], sizeof(playerNames[i]), "Player %d", i + 1); + } +} + +// The name shown for a fighter: the screen name for this screen's own, and "Bot" with its number for the rest. +const char* Ui::name_get(Game& game, int fighter) +{ + // Online, a player's name, numbered when someone earlier in the game has the same one ("Fighter", "Fighter (2)"). + if (game.session.isActive && game.match.owners[fighter] >= 0) + { + std::string name = game.session.base_name_get(game, game.match.owners[fighter]); + int same = 1; + for (int i = 0; i < fighter; i++) + same += game.match.owners[i] >= 0 && game.session.base_name_get(game, game.match.owners[i]) == name; + if (same > 1) + name += " (" + std::to_string(same) + ")"; + SDL_strlcpy(onlineNames[fighter], name.c_str(), sizeof(onlineNames[fighter])); + return onlineNames[fighter]; + } + if (fighter == game.local) + return game.settings.name; + return game.is_local(fighter) ? playerNames[fighter] : botNames[fighter]; +} + +void Ui::draw(Game& game) +{ + // Watching a queued game, nothing of it is this screen's own: the HUD is white, and there's no hotbar. + bool isWatching = game.session.is_watching(); + tint = isWatching ? WHITE : game.color_get(game.local, true); + shade = color_shade(tint, SECONDARY_SHADE); + Vec2 mouse = game.input.mouse; + + // The menu covers the match overlays. Hotbars sit under them, except in the sandbox menu they slide into. With + // several players on this screen in a match, each gets a compact panel along the bottom instead. + queues_draw(game); + bars_draw(game); + game.chat.overhead_draw(game); + Vec2 camera = game.renderer.camera; + game.renderer.camera = {}; + offscreen_draw(game, camera); + bool isBarsRaised = game.menu.is_bars_open(); + bool isShared = game.match.state != MATCH_PRACTICE && game.local_count() > 1; + if (!isBarsRaised && !isShared && !isWatching) + { + hotbar_draw(game); + cooldowns_draw(game); + } + if (isShared) + panels_draw(game); + else if (!isWatching) + status_draw(game); + match_draw(game); + if (!isShared) + { + rivals_draw(game); + spectate_draw(game); + } + game.chat.draw(game); + game.menu.draw(game); + if (game.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared) + countdown_aside_draw(game, game.input.devices[DEVICE_KEYBOARD].isAiming); + if (isBarsRaised) + hotbar_draw(game); + crosshairs_draw(game, camera); + queue_tooltip_draw(game); + if (active && !isActiveSeen) + widget_release(game); + isActiveSeen = false; +} + +// Moves a level by step within range with a sound. Outside the menu it also pops up above the player. +void Ui::level_step(Game& game, int& level, int step) +{ + int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); + if (next == level) + return; + + bool isUp = next > level; + level = next; + game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN); + if (game.menu.isOpen) + return; + + Fighter& fighter = game.local_get(); + Vec2 head = {fighter.center_get().x, fighter.body.box.y}; + game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? tint : shade); +} + +// Stacked above each living fighter: in a deathmatch its health bar, then (in a match, or a lobby's sandbox with others +// in it) its name in its colour, then the local fighter's charge bar while charging, which blinks once full. +void Ui::bars_draw(Game& game) +{ + Match& match = game.match; + Renderer& renderer = game.renderer; + + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (!fighter.isAlive) + continue; + Rect box = fighter.body.box; + float centerX = box.x + box.w / 2; + float bottom = std::round(box.y - BAR_GAP); + Color color = game.color_get(i); + + bool isMatch = match.state != MATCH_PRACTICE; + if (isMatch && !match.is_racing()) + { + float maxHealth = match.health_get(); + Rect bar = {std::round(centerX - HEALTH_BAR_WIDTH / 2), bottom - HEALTH_BAR_HEIGHT, HEALTH_BAR_WIDTH, + HEALTH_BAR_HEIGHT}; + bar_draw(renderer, bar, std::max(0.0f, fighter.health) / maxHealth, color); + bottom = bar.y - BAR_GAP; + } + // Names show in a match, and in a lobby's sandbox once there's someone else to tell apart. + bool isNamed = isMatch || (game.session.isActive && game.fighterCount > 1); + if (isNamed) + { + // Online, the player's connection beside it, the two centred together. + const char* name = name_get(game, i); + Vec2 size = game.text.size_get(name); + bottom -= size.y; + float ping = game.session.isActive ? game.session.ping_get(game, match.owners[i]) : PING_NONE; + float width = size.x + (ping == PING_NONE ? 0 : NAME_PING_GAP + ping_width_get(game, ping)); + float left = std::round(centerX - width / 2); + game.text.draw(renderer, name, {.color = color, .position = {left, bottom}}); + if (ping != PING_NONE) + ping_draw(game, {left + size.x + NAME_PING_GAP, bottom}, ping, color.a); + bottom -= BAR_GAP; + } + + if (i != game.local || fighter.charge < CHARGE_VISIBLE || game.session.is_watching()) + continue; + float fill = (fighter.charge - CHARGE_VISIBLE) / (1 - CHARGE_VISIBLE); + bool isShown = fighter.charge < 1 || fighter.is_flashing(); + Rect bar = {std::round(centerX - CHARGE_BAR_WIDTH / 2), bottom - CHARGE_BAR_HEIGHT, CHARGE_BAR_WIDTH, + CHARGE_BAR_HEIGHT}; + bar_draw(renderer, bar, isShown ? fill : 0, color); + } +} + +// A bordered bar filled from the left by fill, from 0 to 1. +void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color) +{ + panel_draw(renderer, bar, color); + renderer.rect_draw({bar.x + PANEL_BORDER, bar.y + PANEL_BORDER, std::round((bar.w - PANEL_BORDER * 2) * fill), + bar.h - PANEL_BORDER * 2}, + color); +} + +// In practice, a hint to open the menu (with its key), fading once it first opens. In a match, the round wins and +// clock along the top, and a banner for the countdown (with who's ready, a Ready button and a tip), the start of the +// fight, the end of a round and the end of the match. +void Ui::match_draw(Game& game) +{ + Match& match = game.match; + char text[TOOLTIP_LENGTH]; + isTipChosen &= match.state == MATCH_COUNTDOWN; + Input& input = game.input; + bool isPad = input.devices[DEVICE_KEYBOARD].isAiming; + if (match.state == MATCH_PRACTICE) + { + char key[TOOLTIP_LENGTH]; + char reset[TOOLTIP_LENGTH]; + Binding menu = input.bindings[CONTROL_MENU]; + Binding ready = input.bindings[CONTROL_READY]; + input.binding_name_get(isPad ? menu.pad : menu.key, isPad, key, sizeof(key)); + input.binding_name_get(isPad ? ready.pad : ready.key, isPad, reset, sizeof(reset)); + SDL_snprintf(text, sizeof(text), "%s: Menu %s: Reset", key, reset); + text_center_draw(game, text, CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT, 1, hintAlpha); + return; + } + + bool isRace = match.rules.mode == MODE_RACE; + if (isRace) + race_draw(game); + else + { + // The score: each fighter's wins in its own colour, the dashes between them white. It's centred along the top, + // unless a long weapon hotbar reaches it, when it moves right to clear the hotbar (the clock under it moves + // with it). + Renderer& renderer = game.renderer; + int length = 0; + for (int i = 0; i < game.fighterCount; i++) + length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]); + Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; + float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; + float hotbarRight = HOTBAR_MARGIN + game.local_get().weaponSlots * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP; + float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); + float center = x + scoreWidth / 2; + for (int i = 0; i < game.fighterCount; i++) + { + char piece[TOOLTIP_LENGTH]; + if (i > 0) + { + game.text.draw(renderer, " - ", {.color = WHITE, .position = {x, HOTBAR_MARGIN}, .scale = scale}); + x += game.text.size_get(" - ", FONT_SMALL, SCORE_SCALE).x; + } + SDL_snprintf(piece, sizeof(piece), "%d", match.wins[i]); + game.text.draw(renderer, piece, + {.color = game.color_get(i), .position = {x, HOTBAR_MARGIN}, .scale = scale}); + x += game.text.size_get(piece, FONT_SMALL, SCORE_SCALE).x; + } + + // The round's clock, in white: counting down to the time limit, or up when there's none. + float limit = match.time_limit_get(); + int seconds = std::ceil(limit > 0 ? std::max(0.0f, limit - match.clock) : match.clock); + SDL_snprintf(text, sizeof(text), "%d:%02d", seconds / (int)SECONDS_PER_MINUTE, + seconds % (int)SECONDS_PER_MINUTE); + float width = game.text.size_get(text).x; + game.text.draw(renderer, text, + {.color = WHITE, + .position = {std::round(center - width / 2), + HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}}); + } + + switch (match.state) + { + case MATCH_COUNTDOWN: + { + // The arena and HUD dim behind the countdown. + float fade = std::min(1.0f, (match.countdown - match.timer) / LOADOUT_FADE); + game.renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, COUNTDOWN_DIM * fade)); + float y = COUNTDOWN_Y; + CountdownInfo info = countdown_info_get(game, isPad); + bool isLocalReady = match.isReady[game.local]; + if (!isTipChosen) + tip = random_get(0, std::size(TIPS)); + isTipChosen = true; + if (game.menu.isOpen) + break; + + SDL_snprintf(text, sizeof(text), "%s %d", info.round, info.seconds); + text_center_draw(game, text, y, ROUND_SCALE); + y += game.text.line_height_get(FONT_SMALL, ROUND_SCALE) + READY_GAP; + SDL_snprintf(text, sizeof(text), isLocalReady ? "%d/%d Ready" : "%d/%d Ready - Press %s to Ready Up", + info.ready, game.fighterCount, info.key); + text_center_draw(game, text, y, 1); + y += LINE_HEIGHT + READY_GAP; + if (game.local_count() > 1 && (match.isWeaponPick || match.isTrinketPick)) + { + picks_draw(game, y); + break; + } + + widgets_begin(1, !game.menu.isOpen); + widgets_enable(!isLocalReady); + Rect button = {std::round((CANVAS_WIDTH - READY_BUTTON_WIDTH) / 2), y, READY_BUTTON_WIDTH, + READY_BUTTON_HEIGHT}; + input.devices[game.devices[game.local]].isReady |= + button_draw(game, button, isLocalReady ? "Ready!" : "Ready"); + widgets_enable(true); + y += READY_BUTTON_HEIGHT + READY_GAP; + + SDL_snprintf(text, sizeof(text), "Tip: %s", TIPS[tip]); + text_center_draw(game, text, y, 1); + y += LINE_HEIGHT + TIP_GAP; + loadoutTop = y; + if (!game.menu.isOpen && game.local_count() == 1) + loadout_draw(game, fade, y); + break; + } + case MATCH_FIGHT: + // The loadout fades out under the banner. + if (match.timer > -LOADOUT_FADE && game.local_count() == 1) + { + float fade = 1 + match.timer / LOADOUT_FADE; + game.renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, COUNTDOWN_DIM * fade)); + loadout_draw(game, fade, loadoutTop); + } + if (match.timer > -FIGHT_BANNER_TIME) + text_center_draw(game, isRace ? "Go!" : "Fight!", BANNER_Y, BANNER_SCALE); + break; + case MATCH_ROUND_OVER: + if (match.winner >= 0) + SDL_snprintf(text, sizeof(text), "%s Wins!", name_get(game, match.winner)); + else + SDL_strlcpy(text, "Draw", sizeof(text)); + // A win is announced in the winner's colour. + text_center_draw(game, text, BANNER_Y, ROUND_SCALE, + match.winner >= 0 ? game.color_get(match.winner) : tint); + break; + case MATCH_OVER: + { + if (match.winner >= 0) + SDL_snprintf(text, sizeof(text), isRace ? "%s Wins!" : "%s Won the Match!", + name_get(game, match.winner)); + else + SDL_strlcpy(text, "Nobody Finished", sizeof(text)); + text_center_draw(game, text, BANNER_Y, BANNER_SCALE, + match.winner >= 0 ? game.color_get(match.winner) : tint); + float y = BANNER_Y + game.text.line_height_get(FONT_SMALL, BANNER_SCALE); + if (isRace) + y = results_draw(game, y); + if (!game.session.isActive) + { + text_center_draw(game, isPad ? "B: Sandbox" : "Esc: Sandbox", y, 1); + break; + } + // Online, a queued game's players each say whether to play it again (listed under), or leave. + if (!game.session.is_watching()) + { + Binding binding = input.bindings[CONTROL_READY]; + char key[TOOLTIP_LENGTH]; + input.binding_name_get(isPad ? binding.pad : binding.key, isPad, key, sizeof(key)); + SDL_snprintf(text, sizeof(text), "%s: Play Again Esc: Leave", key); + text_center_draw(game, text, y, 1); + y += LINE_HEIGHT * 2; + } + for (int i = 0; i < game.fighterCount; i++) + { + if (match.owners[i] < 0 || match.isGone[i]) + continue; + SDL_snprintf(text, sizeof(text), "- %s%s", name_get(game, i), + match.againTicks > 0 ? " [Starting!]" + : match.isReady[i] ? " [Play Again]" + : ""); + text_center_draw(game, text, y, 1, game.color_get(i, true)); + y += LINE_HEIGHT; + } + break; + } + default: + break; + } +} + +// Minutes, seconds and hundredths, like 1:05.25. +static void time_format(char* text, int size, float seconds) +{ + int minutes = seconds / SECONDS_PER_MINUTE; + SDL_snprintf(text, size, "%d:%05.2f", minutes, seconds - minutes * SECONDS_PER_MINUTE); +} + +// Along the top of a race: the clock, and a track with a dot in each racer's colour showing how far along the course +// it is. +void Ui::race_draw(Game& game) +{ + char text[TOOLTIP_LENGTH]; + time_format(text, sizeof(text), game.match.clock); + text_center_draw(game, text, HOTBAR_MARGIN, SCORE_SCALE); + + float left = std::round((CANVAS_WIDTH - TRACK_WIDTH) / 2); + float y = HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE) + TRACK_DOT; + game.renderer.rect_draw({left, y - TRACK_HEIGHT / 2, TRACK_WIDTH, TRACK_HEIGHT}, shade); + float length = game.world.size_get().x; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + float progress = game.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f); + float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2); + Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT}; + game.renderer.rect_draw(dot, game.color_get(i)); + } +} + +// The race's finishing order from y down, one line per racer in its colour, with its time (or DNF). Returns the y +// below the last line. +float Ui::results_draw(Game& game, float y) +{ + Match& match = game.match; + int order[MAX_FIGHTERS]; + for (int i = 0; i < game.fighterCount; i++) + order[i] = i; + auto rank = [&](int fighter) { + return match.finishTimes[fighter] >= 0 ? match.finishTimes[fighter] : INFINITY; + }; + std::stable_sort(order, order + game.fighterCount, [&](int a, int b) { return rank(a) < rank(b); }); + + for (int place = 0; place < game.fighterCount; place++) + { + int fighter = order[place]; + char time[TOOLTIP_LENGTH]; + char text[TOOLTIP_LENGTH]; + if (match.finishTimes[fighter] >= 0) + time_format(time, sizeof(time), match.finishTimes[fighter]); + else + SDL_strlcpy(time, "DNF", sizeof(time)); + SDL_snprintf(text, sizeof(text), "%d. %s %s", place + 1, name_get(game, fighter), time); + float width = game.text.size_get(text).x; + game.text.draw(game.renderer, text, + {.color = game.color_get(fighter), .position = {std::round((CANVAS_WIDTH - width) / 2), y}}); + y += LINE_HEIGHT; + } + return y; +} + +// A weapon's level and its main attack in one line for the round's loadout overview, like "26 dmg, every 0.7s, 320u" +// (the charged attack when the plain one doesn't hurt, as a mine's detonator doesn't). +static void compact_stats_get(Game& game, Fighter& fighter, int weapon, int level, char* text, int size) +{ + AttackSummary summary = attack_summary_get(fighter, weapon, level, false); + if (!summary.isDamaging) + summary = attack_summary_get(fighter, weapon, level, true); + char every[STAT_LENGTH]; + SDL_snprintf(every, sizeof(every), "%g", std::round(summary.every * 100) / 100); + int length = SDL_snprintf(text, size, "(Level %d)\n%g dmg%s, every %ss", level + 1, summary.damage, + summary.pieces > 1 ? " each" : "", every); + if (summary.range > 0 && !std::isinf(summary.range)) + SDL_snprintf(text + length, size - length, ", %gu", std::round(summary.range)); +} + +// How wide the tooltip's text runs in the lines beside its icon (those starting within the icon's height), which +// leave room for it. +static float beside_icon_get(Game& game, Tooltip& tooltip, Font font) +{ + float lineHeight = game.text.line_height_get(font); + float width = 0; + float y = 0; + for (const char* part : {tooltip.name, (const char*)tooltip.stats, (const char*)tooltip.description}) + { + for (const char* start = part; *part && y < ICON_SIZE;) + { + const char* end = SDL_strchr(start, '\n'); + char line[TOOLTIP_LENGTH]; + SDL_strlcpy(line, start, std::min((int)sizeof(line), (int)((end ? end - start : SDL_strlen(start)) + 1))); + width = std::max(width, game.text.size_get(line, font).x); + y += lineHeight; + if (!end) + break; + start = end + 1; + } + } + return width; +} + +// The local fighter's loadout laid out in a centred grid between top and bottom: weapons and trinkets side by side +// when they all fit in one row, otherwise weapons above trinkets, a kind's panels wrapping onto more rows as needed, +// with a line between the two kinds (down between them, or across). Every panel is as wide as the widest so they line +// up, and a row as tall as its kind's tallest. It never moves up over what's above it: it takes the fullest style that +// fits, from names, levels, trinket effects and descriptions (weapon numbers and flavor quotes left out), the same in +// the small font, then names and levels beside their icons (in either font), down to just the icons, which always fit. +LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) +{ + Fighter& fighter = game.local_get(); + int counts[2] = {fighter.weaponSlots, fighter.trinketSlots}; + float space = CANVAS_WIDTH - HOTBAR_MARGIN * 2; + LoadoutLayout layout; + for (int style = 0; style < LOADOUT_STYLE_COUNT; style++) + { + layout.style = style; + bool isBrief = style == LOADOUT_BRIEF || style == LOADOUT_BRIEF_SMALL; + Font font = style == LOADOUT_FULL_SMALL || style == LOADOUT_BRIEF_SMALL ? FONT_TINY : FONT_SMALL; + float width = SLOT_SIZE; + float heights[2] = {SLOT_SIZE, SLOT_SIZE}; + for (int kind = 0; kind < 2 && style != LOADOUT_ICONS; kind++) + { + bool isTrinket = kind == 1; + for (int i = 0; i < counts[kind]; i++) + { + int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[i]; + if (item < 0) + continue; + int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i]; + Tooltip& tooltip = layout.tooltips[kind][i] = tooltip_build(game, isTrinket, item, level); + tooltip.flavor[0] = '\0'; + tooltip.rowCount = 0; + if (isTrinket && !isBrief) + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s", level + 1, + TRINKET_DEFS[item].effects[level]); + else if (!isBrief) + compact_stats_get(game, fighter, item, level, tooltip.stats, sizeof(tooltip.stats)); + else + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)", level + 1); + if (isBrief) + tooltip.description[0] = '\0'; + // The icon sits in the top-right corner, beside the first lines. + Vec2 measured = tooltip_size(game, tooltip, font); + width = std::max({width, measured.x + TOOLTIP_PADDING * 2, + beside_icon_get(game, tooltip, font) + ICON_SIZE + TOOLTIP_PADDING * 3}); + heights[kind] = + std::max({heights[kind], measured.y + TOOLTIP_PADDING * 2, ICON_SIZE + TOOLTIP_PADDING * 2}); + } + } + + // A line parts weapons from trinkets: down between them when they share a row, across between their rows + // otherwise. + // (only when both kinds have something to show). + auto is_filled = [&](int* loadout, int count) { + return std::any_of(loadout, loadout + count, [](int item) { return item >= 0; }); + }; + bool isDivided = is_filled(fighter.weaponLoadout, counts[0]) && is_filled(fighter.trinketLoadout, counts[1]); + float divide = isDivided ? LOADOUT_DIVIDER + LOADOUT_GAP : 0; + int fit = std::max(1, (int)((space - divide + LOADOUT_GAP) / (width + LOADOUT_GAP))); + bool isShared = fit >= counts[0] + counts[1]; + int columns = isShared ? counts[0] + counts[1] : std::min(fit, std::max(counts[0], counts[1])); + float gridWidth = width * columns + LOADOUT_GAP * (columns - 1) + (isShared ? divide : 0); + float rowTop = top; + layout.divider = {}; + for (int kind = 0; kind < 2; kind++) + { + float height = isShared ? std::max(heights[0], heights[1]) : heights[kind]; + for (int i = 0; i < counts[kind]; i++) + { + int cell = isShared ? kind * counts[0] + i : i; + float shift = isShared && kind == 1 ? divide : 0; + layout.panels[kind][i] = {cell % columns * (width + LOADOUT_GAP) + shift, + rowTop + cell / columns * (height + LOADOUT_GAP), width, height}; + } + if (isShared && kind == 0 && isDivided) + layout.divider = {counts[0] * (width + LOADOUT_GAP), top, LOADOUT_DIVIDER, height}; + if (isShared) + continue; + rowTop += (counts[kind] + columns - 1) / columns * (height + LOADOUT_GAP); + if (kind == 0 && isDivided) + { + layout.divider = {0, rowTop, gridWidth, LOADOUT_DIVIDER}; + rowTop += divide; + } + } + float left = std::round((CANVAS_WIDTH - gridWidth) / 2); + layout.divider.x += left; + float gridBottom = top; + for (int kind = 0; kind < 2; kind++) + for (int i = 0; i < counts[kind]; i++) + { + layout.panels[kind][i].x += left; + gridBottom = std::max(gridBottom, layout.panels[kind][i].y + layout.panels[kind][i].h); + } + layout.bounds = {left, top, gridWidth, gridBottom - top}; + if (gridWidth <= space && gridBottom <= bottom) + break; + } + return layout; +} + +// The loadout overview (see loadout_layout) from top down, faded by alpha. +void Ui::loadout_draw(Game& game, float alpha, float top) +{ + Fighter& fighter = game.local_get(); + LoadoutLayout layout = loadout_layout(game, top, CANVAS_HEIGHT - HOTBAR_MARGIN); + int counts[2] = {fighter.weaponSlots, fighter.trinketSlots}; + Font font = layout.style == LOADOUT_FULL_SMALL || layout.style == LOADOUT_BRIEF_SMALL ? FONT_TINY : FONT_SMALL; + Color color = color_fade(tint, alpha); + if (layout.divider.w > 0) + game.renderer.rect_draw(layout.divider, color); + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + for (int i = 0; i < counts[kind]; i++) + { + int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[i]; + if (item < 0) + continue; + Rect panel = layout.panels[kind][i]; + if (layout.style == LOADOUT_ICONS) + { + slot_draw(game, {.rect = panel, + .alpha = alpha, + .item = item, + .level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i], + .isLit = true, + .isTrinket = isTrinket}); + continue; + } + panel_draw(game.renderer, panel, color); + tooltip_text_draw(game, layout.tooltips[kind][i], + {.color = color, .position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}, + font); + icon_draw(game, isTrinket, item, + {.color = color, + .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, + panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); + } + } +} + +// Text centred across the canvas with its top at y. +// What the countdown shows: the round's name, whole seconds left, how many are ready, and the key that readies up. +static CountdownInfo countdown_info_get(Game& game, bool isPad) +{ + Match& match = game.match; + CountdownInfo info; + if (match.rules.mode == MODE_RACE) + SDL_strlcpy(info.round, "Race", sizeof(info.round)); + else if (match.is_final_round(game)) + SDL_strlcpy(info.round, "Final Round", sizeof(info.round)); + else + SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1); + info.seconds = (int)std::ceil(match.timer); + for (int i = 0; i < game.fighterCount; i++) + info.ready += match.isReady[i]; + Binding binding = game.input.bindings[CONTROL_READY]; + game.input.binding_name_get(isPad ? binding.pad : binding.key, isPad, info.key, sizeof(info.key)); + return info; +} + +// The countdown while the menu covers the middle (as when choosing items), drawn over the menu's dimming: the round and +// seconds left in the margin left of the menu, who's ready in the margin right of it (with the key to ready up until +// this screen has), and the tip under the hotbars, below the gamepad hints if they're showing. The round's name drops +// to small text if it's too wide for the margin at its usual size. +void Ui::countdown_aside_draw(Game& game, bool isPad) +{ + CountdownInfo info = countdown_info_get(game, isPad); + const char* round = info.round; + int seconds = info.seconds; + int ready = info.ready; + const char* key = game.match.isReady[game.local] ? nullptr : info.key; + float margin = (CANVAS_WIDTH - MENU_WIDTH) / 2; + float middle = MENU_TOP + (HEADER_HEIGHT + BODY_HEIGHT) / 2; + auto line_draw = [&](const char* line, float centerX, float y, float scale) { + float width = game.text.size_get(line, FONT_SMALL, scale).x; + game.text.draw( + game.renderer, line, + {.color = tint, .position = {std::round(centerX - width / 2), std::round(y)}, .scale = {scale, scale}}); + return game.text.line_height_get(FONT_SMALL, scale); + }; + + float labelScale = game.text.size_get(round, FONT_SMALL, ASIDE_LABEL_SCALE).x <= margin - ASIDE_PADDING * 2 + ? ASIDE_LABEL_SCALE + : 1; + float height = + game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, ROUND_SCALE); + float y = middle - height / 2; + y += line_draw(round, margin / 2, y, labelScale); + char text[TOOLTIP_LENGTH]; + SDL_snprintf(text, sizeof(text), "%d", seconds); + line_draw(text, margin / 2, y, ROUND_SCALE); + + float rightX = CANVAS_WIDTH - margin / 2; + y = middle - (key ? LINE_HEIGHT * 3 : LINE_HEIGHT) / 2; + SDL_snprintf(text, sizeof(text), "%d/%d Ready", ready, game.fighterCount); + y += line_draw(text, rightX, y, 1); + if (key) + { + SDL_snprintf(text, sizeof(text), "Press %s", key); + y += line_draw(text, rightX, y, 1); + line_draw("to Ready Up", rightX, y, 1); + } + + y = BAR_Y + SLOT_SIZE + HINT_GAP + (isFocusShown ? LINE_HEIGHT : 0); + SDL_snprintf(text, sizeof(text), "Tip: %s", TIPS[tip]); + text_center_draw(game, text, y, 1); +} + +void Ui::text_center_draw(Game& game, const char* text, float y, float scale, float alpha) +{ + text_center_draw(game, text, y, scale, color_fade(tint, alpha)); +} + +void Ui::text_center_draw(Game& game, const char* text, float y, float scale, Color color) +{ + float width = game.text.size_get(text, FONT_SMALL, scale).x; + game.text.draw( + game.renderer, text, + {.color = color, .position = {std::round((CANVAS_WIDTH - width) / 2), std::round(y)}, .scale = {scale, scale}}); +} + +// Top right: during a match, the local fighter's health as a number beside a bar, then damage per second and the +// local fighter's speed in units per second. +void Ui::status_draw(Game& game) +{ + char text[TOOLTIP_LENGTH]; + float right = CANVAS_WIDTH - HOTBAR_MARGIN; + float y = HOTBAR_MARGIN; + float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE); + + if (game.match.state != MATCH_PRACTICE && !game.match.is_racing()) + { + Fighter& fighter = game.local_get(); + float maxHealth = game.match.health_get(); + Rect bar = {right - STATUS_BAR_WIDTH, std::round(y + (rowHeight - STATUS_BAR_HEIGHT) / 2), STATUS_BAR_WIDTH, + STATUS_BAR_HEIGHT}; + bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint); + SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); + float width = game.text.size_get(text, FONT_SMALL, DPS_SCALE).x; + game.text.draw( + game.renderer, text, + {.color = tint, .position = {std::round(bar.x - STATUS_GAP - width), y}, .scale = {DPS_SCALE, DPS_SCALE}}); + y += rowHeight; + } + + // The DPS and speed meters each show only if they're on in the settings. + auto meter_draw = [&](const char* label) { + float width = game.text.size_get(label, FONT_SMALL, DPS_SCALE).x; + game.text.draw(game.renderer, label, + {.color = tint, .position = {std::round(right - width), y}, .scale = {DPS_SCALE, DPS_SCALE}}); + y += rowHeight; + }; + if (game.settings.isDpsShown) + { + SDL_snprintf(text, sizeof(text), "%.1f DPS", game.effects.dps_get()); + meter_draw(text); + } + if (game.settings.isSpeedShown) + { + SDL_snprintf(text, sizeof(text), "%d u/s", (int)vec2_length(game.local_get().body.velocity)); + meter_draw(text); + } +} + +// The weapon bar (lit on the selected slot) and the trinket bar (lit where filled). The menu decides where +// they sit. Hovering a filled slot outside the menu shows its tooltip. +void Ui::hotbar_draw(Game& game) +{ + Vec2 mouse = game.input.mouse; + Fighter& fighter = game.local_get(); + Slot hovered; + + for (int i = 0; i < fighter.weaponSlots + fighter.trinketSlots; i++) + { + bool isTrinket = i >= fighter.weaponSlots; + int index = isTrinket ? i - fighter.weaponSlots : i; + int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int item = isTrinket ? fighter.trinketLoadout[index] : fighter.weaponLoadout[index]; + // In the HUD, slots are dimmed until the mouse comes over them; under the menu they're at full brightness. + float hover = slotHovers[isTrinket][index]; + Slot slot = { + .rect = game.menu.slot_rect(isTrinket, index, count), + .alpha = game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover, + .item = item, + .level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[index], + .label = index + 1, + .isHoverable = true, + .isLit = isTrinket ? item >= 0 : index == fighter.slot, + .isTrinket = isTrinket, + .isOpaque = true, + .isTarget = game.menu.target_get(game, isTrinket) == index, + }; + slot_draw(game, slot); + if (item >= 0 && is_point_in_rect(mouse, slot.rect)) + hovered = slot; + } + + if (hovered.item < 0 || game.menu.isOpen) + return; + Tooltip tooltip = tooltip_build(game, hovered.isTrinket, hovered.item, hovered.level); + tooltip_draw(game, tooltip); +} + +// A bordered box with the item's icon, a stack of chevrons in the corner for its level, and an optional number. +void Ui::slot_draw(Game& game, Slot slot) +{ + Renderer& renderer = game.renderer; + bool isHovered = slot.isHoverable && !isFocusShown && is_point_in_rect(game.input.mouse, slot.rect); + Color base = slot.isLit || isHovered || slot.isTarget ? tint : shade; + Color color = slot.isOpaque ? color_shade(base, slot.alpha) : color_fade(base, slot.alpha); + float fill = slot.isOpaque ? 1 : slot.alpha; + panel_draw(renderer, slot.rect, color, fill); + if (slot.isTarget) + renderer.rect_draw({slot.rect.x + TARGET_INSET, slot.rect.y + slot.rect.h + TARGET_GAP, + slot.rect.w - TARGET_INSET * 2, TARGET_HEIGHT}, + color); + if (isHovered) + { + Rect rect = slot.rect; + float inset = PANEL_BORDER + HOVER_GAP; + chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, color); + inset += HOVER_BORDER; + chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, + color_fade(BLACK, fill)); + } + + if (slot.item >= 0) + { + icon_draw(game, slot.isTrinket, slot.item, + {.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}}); + + Vec2 corner = {slot.rect.x + slot.rect.w - CHEVRON_INSET, slot.rect.y + slot.rect.h - CHEVRON_INSET}; + for (int i = 0; i <= slot.level; i++) + game.sprites.ui.draw(renderer, "Chevron", 0, + {.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEP}}); + } + + if (slot.label <= 0) + return; + char label[] = {char('0' + slot.label), 0}; + game.text.draw(renderer, label, + {.color = color, .position = {slot.rect.x + LABEL_OFFSET.x, slot.rect.y + LABEL_OFFSET.y}}); +} + +// A number for a stats table: whole when it's whole, otherwise to two decimal places at most. +static void number_format(char* text, int size, float value) +{ + SDL_snprintf(text, size, "%g", std::round(value * 100) / 100); +} + +// What a summary row shows for an attack, or "" where it doesn't apply. +static void stat_format(char* text, int size, int row, const AttackSummary& summary, const AttackDef& attack, + bool isCharged) +{ + char number[STAT_LENGTH]; + text[0] = '\0'; + switch (row) + { + case 0: + { + if (summary.pieces <= 1) + { + SDL_strlcpy(text, attack.label, size); + break; + } + int length = SDL_strlen(attack.label); + bool isSibilant = + length > 0 && + (SDL_strchr("sx", attack.label[length - 1]) || + (length > 1 && attack.label[length - 1] == 'h' && SDL_strchr("cs", attack.label[length - 2]))); + SDL_snprintf(text, size, "%d %s%s", summary.pieces, attack.label, isSibilant ? "es" : "s"); + break; + } + case 1: + if (summary.isDamaging) + { + number_format(number, sizeof(number), summary.damage); + SDL_snprintf(text, size, summary.pieces > 1 ? "%s each" : "%s", number); + } + break; + case 2: + number_format(number, sizeof(number), summary.every); + SDL_snprintf(text, size, isCharged ? "%ss charge" : "%ss", number); + break; + case 3: + if (std::isinf(summary.range)) + SDL_strlcpy(text, "endless", size); + else if (summary.range > 0) + SDL_snprintf(text, size, "%gu", std::round(summary.range)); + break; + case 4: + if (summary.area > 0) + SDL_snprintf(text, size, "%gu", std::round(summary.area)); + break; + case 5: + if (summary.speed > 0) + SDL_snprintf(text, size, "%gu/s", std::round(summary.speed)); + break; + case 6: + if (summary.out > 0) + SDL_snprintf(text, size, "%d", summary.out); + break; + case 7: + if (summary.spread > 0) + { + number_format(number, sizeof(number), summary.spread); + SDL_snprintf(text, size, "%s deg", number); + } + break; + case 8: + if (summary.lunge != 0) + SDL_snprintf(text, size, summary.lunge > 0 ? "%g forward" : "%g back", + std::round(std::abs(summary.lunge))); + break; + } +} + +// Fills a weapon tooltip's stats table: a row per stat the weapon has (fired, damage, how often, range, blast, speed, +// how many out at once, spread, lunge), always in that order, for its attack and its charged attack as this fighter +// would fire them. Values its trinkets change are marked; with a preview, ones the next level changes add "> next". +void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip) +{ + constexpr const char* LABELS[] = {"Fires", "Damage", "Every", "Range", "Blast", + "Speed", "Out at once", "Spread", "Lunge"}; + const WeaponDef& def = WEAPON_DEFS[weapon]; + tooltip.rowCount = 0; + for (int row = 0; row < (int)std::size(LABELS); row++) + { + StatRow& stat = tooltip.rows[tooltip.rowCount]; + stat = {.label = LABELS[row]}; + bool isShown = false; + for (int column = 0; column < 2; column++) + { + bool isCharged = column == 1; + const AttackDef& attack = isCharged ? def.charged : def.normal; + AttackSummary summary = attack_summary_get(fighter, weapon, level, isCharged); + char* value = stat.values[column]; + stat_format(value, STAT_LENGTH, row, summary, attack, isCharged); + bool changes[] = {summary.isPiecesChanged, + summary.isDamageChanged, + summary.isEveryChanged, + summary.isRangeChanged, + summary.isAreaChanged, + summary.isSpeedChanged, + false, + summary.isSpreadChanged, + summary.isLungeChanged}; + stat.isChanged[column] = changes[row]; + if (isPreview && *value) + { + char next[STAT_LENGTH]; + stat_format(next, sizeof(next), row, attack_summary_get(fighter, weapon, level + 1, isCharged), attack, + isCharged); + if (SDL_strcmp(next, value) != 0) + SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " > %s", next); + } + isShown |= *value != '\0'; + } + tooltip.rowCount += isShown; + } +} + +// The stats table's column positions (label, attack, charged) from the left, and its size, in a font. +static Vec2 table_layout(Game& game, Tooltip& tooltip, Font font, float* columns) +{ + Text& text = game.text; + float widths[3] = {0, text.size_get(TABLE_HEADERS[0], font).x, text.size_get(TABLE_HEADERS[1], font).x}; + for (int i = 0; i < tooltip.rowCount; i++) + { + StatRow& row = tooltip.rows[i]; + widths[0] = std::max(widths[0], text.size_get(row.label, font).x); + for (int column = 0; column < 2; column++) + widths[column + 1] = std::max(widths[column + 1], text.size_get(row.values[column], font).x); + } + columns[0] = 0; + columns[1] = widths[0] + TABLE_GAP; + columns[2] = columns[1] + widths[1] + TABLE_GAP; + float lineHeight = text.line_height_get(font); + return tooltip.rowCount > 0 ? Vec2{columns[2] + widths[2], (tooltip.rowCount + 1) * lineHeight + TABLE_SPACE} + : Vec2{}; +} + +// One line of the stats table at y: the header for row -1, otherwise a row, labels and plain values gray and values +// the trinkets change in the tint. +void Ui::table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, const float* columns, Font font, + float alpha) +{ + Text& text = game.text; + if (row < 0) + { + for (int column = 0; column < 2; column++) + text.draw(game.renderer, TABLE_HEADERS[column], + {.color = color_fade(tint, alpha), .position = {corner.x + columns[column + 1], corner.y}}, font); + return; + } + StatRow& stat = tooltip.rows[row]; + text.draw(game.renderer, stat.label, {.color = color_fade(shade, alpha), .position = corner}, font); + for (int column = 0; column < 2; column++) + text.draw(game.renderer, stat.values[column], + {.color = color_fade(stat.isChanged[column] ? tint : shade, alpha), + .position = {corner.x + columns[column + 1], corner.y}}, + font); +} + +// A trinket's effects at one level with the next level's after a ">", line by line. Where a line only changes in its +// numbers, just those show both values ("1.15x > 1.3x Attack Rate"); otherwise the whole line does ("Wall Cling > Wall +// Cling, Wall Kick"), split onto a line of its own after the first when that's too wide. +static void effects_preview_get(Game& game, const char* now, const char* next, char* text, int size) +{ + auto lines_split = [](const char* source, std::vector& lines) { + for (const char* start = source;;) + { + const char* end = SDL_strchr(start, '\n'); + lines.emplace_back(start, end ? end - start : SDL_strlen(start)); + if (!end) + break; + start = end + 1; + } + }; + auto words_split = [](const std::string& line) { + std::vector words; + size_t start = 0; + while (start <= line.size()) + { + size_t end = line.find(' ', start); + words.push_back(line.substr(start, end == std::string::npos ? std::string::npos : end - start)); + if (end == std::string::npos) + break; + start = end + 1; + } + return words; + }; + auto is_number = [](const std::string& word) { + return std::any_of(word.begin(), word.end(), [](char c) { return c >= '0' && c <= '9'; }); + }; + + std::vector befores; + std::vector afters; + lines_split(now, befores); + lines_split(next, afters); + std::string result; + for (size_t i = 0; i < std::max(befores.size(), afters.size()); i++) + { + std::string before = i < befores.size() ? befores[i] : ""; + std::string after = i < afters.size() ? afters[i] : ""; + std::vector wordsBefore = words_split(before); + std::vector wordsAfter = words_split(after); + bool isNumbers = !before.empty() && wordsBefore.size() == wordsAfter.size(); + for (size_t w = 0; w < wordsBefore.size() && isNumbers; w++) + isNumbers = wordsBefore[w] == wordsAfter[w] || (is_number(wordsBefore[w]) && is_number(wordsAfter[w])); + std::string line; + if (before == after) + line = before; + else if (isNumbers) + for (size_t w = 0; w < wordsBefore.size(); w++) + line += (w > 0 ? " " : "") + + (wordsBefore[w] == wordsAfter[w] ? wordsBefore[w] : wordsBefore[w] + " > " + wordsAfter[w]); + else if (before.empty()) + line = "> " + after; + else + { + line = before + " > " + after; + if (game.text.size_get(line.c_str()).x > PREVIEW_LINE_MAX) + line = before + "\n> " + after; + } + result += (i > 0 ? "\n" : "") + line; + } + SDL_strlcpy(text, result.c_str(), size); +} + +// Name, numbers at a level and description of a weapon or trinket. Trinkets say how many copies are equipped, and +// when they stack. +Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int owner) +{ + Tooltip tooltip; + tooltip.isTrinket = isTrinket; + tooltip.item = item; + Fighter& fighter = owner >= 0 ? game.fighters[owner] : game.local_get(); + + if (isTrinket) + { + const TrinketDef& def = TRINKET_DEFS[item]; + int copies = fighter.trinket_count(item); + char equipped[TOOLTIP_LENGTH]; + if (copies > 1) + SDL_snprintf(equipped, sizeof(equipped), "%d Copies Equipped", copies); + else + SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); + tooltip.name = def.name; + // In the sandbox, the effects show what the next level changes after a ">", as weapons' stats do. + bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + char effects[TOOLTIP_LENGTH]; + if (isPreview) + effects_preview_get(game, def.effects[level], def.effects[level + 1], effects, sizeof(effects)); + else + SDL_strlcpy(effects, def.effects[level], sizeof(effects)); + if (isPreview) + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d, > Level %d)\n%s\n%s", level + 1, level + 2, + effects, equipped); + else + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s\n%s", level + 1, effects, equipped); + SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s", def.blurb); + if (*def.flavor) + SDL_snprintf(tooltip.flavor, sizeof(tooltip.flavor), "\n\"%s\"", def.flavor); + return tooltip; + } + + const WeaponDef& def = WEAPON_DEFS[item]; + tooltip.name = def.name; + // In the sandbox, values that change at the next level show it after a ">". + bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d, > Level %d)" : "(Level %d)", level + 1, + level + 2); + stats_table_build(fighter, item, level, isPreview, tooltip); + SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s\n\n%s", def.blurb, def.chargeBlurb); + if (*def.flavor) + SDL_snprintf(tooltip.flavor, sizeof(tooltip.flavor), "\n\"%s\"", def.flavor); + return tooltip; +} + +Vec2 Ui::tooltip_size(Game& game, Tooltip& tooltip, Font font) +{ + Text& text = game.text; + Vec2 name = text.size_get(tooltip.name, font); + Vec2 stats = text.size_get(tooltip.stats, font); + Vec2 description = *tooltip.description ? text.size_get(tooltip.description, font) : Vec2{}; + Vec2 flavor = *tooltip.flavor ? text.size_get(tooltip.flavor, font) : Vec2{}; + float columns[3]; + Vec2 table = table_layout(game, tooltip, font, columns); + return {std::max({name.x, stats.x, table.x, description.x, flavor.x}), + name.y + stats.y + table.y + description.y + flavor.y}; +} + +// Stacks the name, gray stats, description and any gray flavor quote from draw.position down, faded by draw.color's +// alpha. +void Ui::tooltip_text_draw(Game& game, Tooltip& tooltip, Draw draw, Font font) +{ + Text& text = game.text; + float alpha = draw.color.a; + Vec2 cursor = draw.position; + + text.draw(game.renderer, tooltip.name, {.color = color_fade(tint, alpha), .position = cursor}, font); + cursor.y += text.size_get(tooltip.name, font).y; + text.draw(game.renderer, tooltip.stats, {.color = color_fade(shade, alpha), .position = cursor}, font); + cursor.y += text.size_get(tooltip.stats, font).y; + float columns[3]; + Vec2 table = table_layout(game, tooltip, font, columns); + for (int row = -1; row < tooltip.rowCount && table.y > 0; row++) + table_row_draw(game, tooltip, row, {cursor.x, cursor.y + TABLE_SPACE + (row + 1) * text.line_height_get(font)}, + columns, font, alpha); + cursor.y += table.y; + if (*tooltip.description) + text.draw(game.renderer, tooltip.description, {.color = color_fade(tint, alpha), .position = cursor}, font); + cursor.y += *tooltip.description ? text.size_get(tooltip.description, font).y : 0; + if (*tooltip.flavor) + text.draw(game.renderer, tooltip.flavor, {.color = color_fade(shade, alpha), .position = cursor}, font); +} + +// The tooltip's text in a scrolling page, a row per line, so it gains a scroll bar whenever it runs taller than the +// area. +void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip) +{ + // The item's icon sits top right, beside its name and level, scrolling with the page (left of its scroll bar). + bool isScrolling = page.height > area.h; + page_begin(game, page, area); + float iconY = area.y - page.scroll; + if (tooltip.item >= 0 && iconY >= area.y) + icon_draw(game, tooltip.isTrinket, tooltip.item, + {.color = tint, + .position = {area.x + area.w - (isScrolling ? SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, + iconY + ICON_SIZE / 2}}); + auto lines = [&](const char* text, Color color) { + for (const char* start = text;;) + { + const char* end = SDL_strchr(start, '\n'); + int length = end ? (int)(end - start) : (int)SDL_strlen(start); + float y = 0; + if (row_get(page, LINE_HEIGHT, y)) + { + char line[TOOLTIP_LENGTH]; + SDL_strlcpy(line, start, std::min(length + 1, (int)sizeof(line))); + game.text.draw(game.renderer, line, {.color = color, .position = {area.x, y}}); + } + if (!end) + break; + start = end + 1; + } + }; + lines(tooltip.name, tint); + lines(tooltip.stats, shade); + // The table drops to the tiny font when it's too wide for the page. + float columns[3]; + Font font = table_layout(game, tooltip, FONT_SMALL, columns).x <= area.w ? FONT_SMALL : FONT_TINY; + table_layout(game, tooltip, font, columns); + float y = 0; + if (tooltip.rowCount > 0) + row_get(page, TABLE_SPACE, y); + for (int row = -1; row < tooltip.rowCount && tooltip.rowCount > 0; row++) + if (row_get(page, game.text.line_height_get(font), y)) + table_row_draw(game, tooltip, row, {area.x, y}, columns, font, 1); + lines(tooltip.description, tint); + if (*tooltip.flavor) + lines(tooltip.flavor, shade); + page_end(game, page); +} + +// Places the tooltip in a panel beside the cursor, kept on screen. +void Ui::tooltip_draw(Game& game, Tooltip& tooltip) +{ + Vec2 mouse = game.input.mouse; + Vec2 size = tooltip_size(game, tooltip); + // The icon sits in the top-right corner, beside the first lines. + if (tooltip.item >= 0) + size = {std::max(size.x, beside_icon_get(game, tooltip, FONT_SMALL) + TOOLTIP_PADDING + ICON_SIZE), + std::max(size.y, ICON_SIZE)}; + Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2, + size.y + TOOLTIP_PADDING * 2}; + panel.x = std::round(std::min(panel.x, CANVAS_WIDTH - panel.w)); + panel.y = std::round(std::min(panel.y, CANVAS_HEIGHT - panel.h)); + + panel_draw(game.renderer, panel, tint); + tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}); + if (tooltip.item >= 0) + icon_draw(game, tooltip.isTrinket, tooltip.item, + {.color = tint, + .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, + panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); +} + +// Black box with a bordered, cut-cornered edge in the given color. The fill fades with the border unless given an +// alpha of its own. +void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha) +{ + chamfer_draw(renderer, rect, border); + chamfer_draw(renderer, + {rect.x + PANEL_BORDER, rect.y + PANEL_BORDER, rect.w - PANEL_BORDER * 2, rect.h - PANEL_BORDER * 2}, + color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a)); +} + +// Fills a rect with its corners cut diagonally, one pixel per step. +void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color) +{ + for (int step = 0; step < CHAMFER; step++) + { + float inset = CHAMFER - step; + renderer.rect_draw({rect.x + inset, rect.y + step, rect.w - inset * 2, 1}, color); + renderer.rect_draw({rect.x + inset, rect.y + rect.h - 1 - step, rect.w - inset * 2, 1}, color); + } + renderer.rect_draw({rect.x, rect.y + CHAMFER, rect.w, rect.h - CHAMFER * 2}, color); +} + +void Ui::widgets_begin(float alpha, bool isLive) +{ + widgetAlpha = alpha; + isWidgetLive = isLive; + isWidgetEnabled = true; +} + +// Disabled widgets draw dimmed and ignore the mouse until enabled again. +void Ui::widgets_enable(bool isEnabled) +{ + isWidgetEnabled = isEnabled; +} + +// Under the mouse, while focus isn't showing (so only one thing lights at a time). +bool Ui::is_hovered(Game& game, Rect box) +{ + return isWidgetLive && isWidgetEnabled && !isFocusShown && is_point_in_rect(game.input.mouse, box); +} + +// Gray text at a widget row's height, for labels and readouts. +void Ui::label_draw(Game& game, const char* text, Vec2 position) +{ + game.text.draw(game.renderer, text, + {.color = widget_color(false), .position = {position.x, position.y + TEXT_INSET}}); +} + +// A box that fills in while on, with its label beside it. Clicking either flips it. +bool Ui::toggle_draw(Game& game, Vec2 position, const char* label, bool& value) +{ + Rect box = {position.x, position.y, WIDGET_HEIGHT, WIDGET_HEIGHT}; + Vec2 size = game.text.size_get(label); + Rect area = {box.x, box.y, box.w + WIDGET_GAP + size.x, box.h}; + bool isFocused = focus_add(&value, 0, area); + bool isMouse = is_hovered(game, area); + bool isHovered = isMouse || isFocused; + bool isChanged = (isMouse && game.input.isClicked) || (isFocused && isConfirmed); + if (isChanged) + { + value = !value; + game.audio.play(SOUND_SELECT); + } + + panel_draw(game.renderer, box, widget_color(isHovered)); + if (value) + game.renderer.rect_draw( + {box.x + CHECK_INSET, box.y + CHECK_INSET, box.w - CHECK_INSET * 2, box.h - CHECK_INSET * 2}, + widget_color(true)); + game.text.draw( + game.renderer, label, + {.color = widget_color(isHovered || value), .position = {box.x + box.w + WIDGET_GAP, box.y + TEXT_INSET}}); + return isChanged; +} + +// A toggle that's one of a set: on while value is its option, and clicking it picks that option. +bool Ui::radio_draw(Game& game, Vec2 position, const char* label, int& value, int option) +{ + Rect box = {position.x, position.y, WIDGET_HEIGHT, WIDGET_HEIGHT}; + Vec2 size = game.text.size_get(label); + Rect area = {box.x, box.y, box.w + WIDGET_GAP + size.x, box.h}; + bool isFocused = focus_add(&value, option, area); + bool isMouse = is_hovered(game, area); + bool isHovered = isMouse || isFocused; + bool isChanged = ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) && value != option; + if (isChanged) + { + value = option; + game.audio.play(SOUND_SELECT); + } + + bool isOn = value == option; + panel_draw(game.renderer, box, widget_color(isHovered)); + if (isOn) + panel_draw(game.renderer, + {box.x + RADIO_INSET, box.y + RADIO_INSET, box.w - RADIO_INSET * 2, box.h - RADIO_INSET * 2}, + widget_color(true)); + game.text.draw( + game.renderer, label, + {.color = widget_color(isHovered || isOn), .position = {box.x + box.w + WIDGET_GAP, box.y + TEXT_INSET}}); + return isChanged; +} + +// A box with its label centred, lighting up under the mouse like a toggle. Returns whether it was clicked. Its focus +// id is its label unless given one (for labels that change or repeat). +bool Ui::button_draw(Game& game, Rect box, const char* label, const void* id) +{ + bool isFocused = focus_add(id ? id : label, 0, box); + bool isMouse = is_hovered(game, box); + bool isHovered = isMouse || isFocused; + bool isClicked = (isMouse && game.input.isClicked) || (isFocused && isConfirmed); + if (isClicked) + game.audio.play(SOUND_SELECT); + panel_draw(game.renderer, box, widget_color(isHovered)); + Vec2 size = game.text.size_get(label); + game.text.draw(game.renderer, label, + {.color = widget_color(isHovered), + .position = {std::round(box.x + (box.w - size.x) / 2), std::round(box.y + (box.h - size.y) / 2)}}); + return isClicked; +} + +// A box filled with a colour, framed lit while value is its option (or it's under the mouse), and clicking it picks +// that option, like a radio. +bool Ui::swatch_draw(Game& game, Rect box, Color color, int& value, int option) +{ + bool isFocused = focus_add(&value, option, box); + bool isMouse = is_hovered(game, box); + bool isChanged = ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) && value != option; + if (isChanged) + { + value = option; + game.audio.play(SOUND_SELECT); + } + bool isOn = value == option; + if (isOn || isMouse || isFocused) + panel_draw(game.renderer, box, widget_color(true)); + float inset = isOn ? SWATCH_INSET_ON : SWATCH_INSET; + chamfer_draw(game.renderer, {box.x + inset, box.y + inset, box.w - inset * 2, box.h - inset * 2}, + color_fade(color, widgetAlpha)); + return isChanged; +} + +// A track filled from the left by value, from 0 to 1. Pressing on it drags the value to the mouse until let go, +// snapping to multiples of step. Focused, left and right step it. +bool Ui::slider_draw(Game& game, Rect track, float& value, float step) +{ + Input& input = game.input; + bool isFocused = focus_add(&value, 0, track, true); + bool isHovered = is_hovered(game, track); + if (isHovered && input.isClicked) + active = &value; + bool isDragging = active == &value; + float before = value; + if (isFocused && adjust != 0) + value = std::clamp(std::round(value / step + adjust) * step, 0.0f, 1.0f); + isHovered |= isFocused; + if (isDragging) + { + isActiveSeen = true; + float inner = track.w - SLIDER_INSET * 2; + value = std::clamp((input.mouse.x - track.x - SLIDER_INSET) / inner, 0.0f, 1.0f); + value = std::round(value / step) * step; + if (!input.is_mouse_down(MOUSE_LEFT) || !isWidgetLive) + active = nullptr; + } + + panel_draw(game.renderer, track, widget_color(isHovered || isDragging)); + game.renderer.rect_draw({track.x + SLIDER_INSET, track.y + SLIDER_INSET, + std::round((track.w - SLIDER_INSET * 2) * value), track.h - SLIDER_INSET * 2}, + widget_color(true)); + return value != before; +} + +// A text box. Clicking it takes the keyboard and shows a blinking caret. Typing adds characters the font has, up to +// size including the terminator, and Enter, Escape or clicking elsewhere lets go. +bool Ui::field_draw(Game& game, Rect box, char* text, int size) +{ + Input& input = game.input; + bool isFocused = focus_add(text, 0, box); + bool isHovered = is_hovered(game, box); + bool isTyping = active == text; + if (!isTyping && ((isHovered && input.isClicked) || (isFocused && isConfirmed))) + { + active = text; + isTyping = true; + SDL_StartTextInput(game.window); + } + + bool isChanged = false; + if (isTyping) + { + isActiveSeen = true; + int length = SDL_strlen(text); + for (int i = 0; i < input.erases && length > 0; i++) + text[--length] = '\0'; + for (const char* character = input.typed; *character && length < size - 1; character++) + { + if (*character < GLYPH_FIRST || *character >= GLYPH_FIRST + GLYPH_COUNT) + continue; + text[length++] = *character; + text[length] = '\0'; + } + isChanged = input.erases > 0 || input.typed[0] != '\0'; + if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered)) + widget_release(game); + } + + panel_draw(game.renderer, box, widget_color(isHovered || isFocused || isTyping)); + Vec2 corner = {box.x + FIELD_INSET, box.y + TEXT_INSET}; + game.text.draw(game.renderer, text, {.color = widget_color(true), .position = corner}); + bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0; + if (isTyping && isCaretShown) + { + float caretX = corner.x + game.text.size_get(text).x; + game.renderer.rect_draw({caretX, box.y + (box.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT}, widget_color(true)); + } + return isChanged; +} + +bool Ui::is_active(void* value) +{ + return active == value; +} + +Color Ui::widget_color(bool isLit) +{ + return color_fade(isLit ? tint : shade, widgetAlpha * (isWidgetEnabled ? 1 : DISABLED_ALPHA)); +} + +// A number between a minus and a plus button that step it. Clicking the number types a new one, taken (clamped to the +// range) on Enter or clicking elsewhere. Focused, left and right step it. +bool Ui::number_draw(Game& game, Rect box, float& value, NumberRange range) +{ + return number_widget(game, box, value, range, &value); +} + +bool Ui::number_draw(Game& game, Rect box, int& value, NumberRange range) +{ + float number = value; + bool isChanged = number_widget(game, box, number, range, &value); + value = std::round(number); + return isChanged; +} + +// The number widget for a value known by id (its own address, which stays the same from frame to frame). +bool Ui::number_widget(Game& game, Rect box, float& value, NumberRange range, void* id) +{ + Input& input = game.input; + Rect minus = {box.x, box.y, box.h, box.h}; + Rect plus = {box.x + box.w - box.h, box.y, box.h, box.h}; + Rect middle = {minus.x + minus.w + CHAMFER, box.y, box.w - box.h * 2 - CHAMFER * 2, box.h}; + float before = value; + bool isTyping = active == id; + bool isFocused = focus_add(id, 0, box, true); + if (isFocused && adjust != 0 && !isTyping) + { + value = std::clamp(value + adjust * range.step, range.min, range.max); + game.audio.play(SOUND_SELECT); + } + + for (Rect button : {minus, plus}) + { + bool isHovered = is_hovered(game, button) || (isFocused && adjust == (button.x == minus.x ? -1 : 1)); + if (isHovered && input.isClicked && !isTyping) + { + value = std::clamp(value + (button.x == minus.x ? -range.step : range.step), range.min, range.max); + game.audio.play(SOUND_SELECT); + } + panel_draw(game.renderer, button, widget_color(isHovered)); + const char* sign = button.x == minus.x ? "-" : "+"; + Vec2 size = game.text.size_get(sign); + game.text.draw(game.renderer, sign, + {.color = widget_color(isHovered), + .position = {std::round(button.x + (button.w - size.x) / 2), button.y + TEXT_INSET}}); + } + + bool isHovered = is_hovered(game, middle); + if (!isTyping && isHovered && input.isClicked) + { + active = id; + isTyping = true; + numberText[0] = '\0'; + SDL_StartTextInput(game.window); + } + if (isTyping) + { + isActiveSeen = true; + int length = SDL_strlen(numberText); + for (int i = 0; i < input.erases && length > 0; i++) + numberText[--length] = '\0'; + for (const char* character = input.typed; *character && length < (int)sizeof(numberText) - 1; character++) + { + if ((*character < '0' || *character > '9') && *character != '.') + continue; + numberText[length++] = *character; + numberText[length] = '\0'; + } + if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered)) + { + if (numberText[0] != '\0') + value = std::clamp((float)SDL_atof(numberText), range.min, range.max); + widget_release(game); + isTyping = false; + } + } + + char text[TOOLTIP_LENGTH]; + if (isTyping) + SDL_strlcpy(text, numberText, sizeof(text)); + else if (value == 0 && range.zero) + SDL_strlcpy(text, range.zero, sizeof(text)); + else + SDL_snprintf(text, sizeof(text), "%g%s", value, range.suffix); + panel_draw(game.renderer, middle, widget_color(isHovered || isFocused || isTyping)); + Vec2 size = game.text.size_get(text); + float textX = std::round(middle.x + (middle.w - size.x) / 2); + game.text.draw(game.renderer, text, {.color = widget_color(true), .position = {textX, middle.y + TEXT_INSET}}); + bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0; + if (isTyping && isCaretShown) + game.renderer.rect_draw({textX + size.x, middle.y + (middle.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT}, + widget_color(true)); + return value != before; +} + +// A section title centred in a row, with a line either side of it. +void Ui::separator_draw(Game& game, Rect row, const char* label) +{ + Vec2 size = game.text.size_get(label); + float textX = std::round(row.x + (row.w - size.x) / 2); + float lineY = std::round(row.y + row.h / 2); + Color color = widget_color(false); + game.renderer.rect_draw({row.x, lineY, textX - SEPARATOR_GAP - row.x, 1}, color); + game.renderer.rect_draw({textX + size.x + SEPARATOR_GAP, lineY, row.x + row.w - textX - size.x - SEPARATOR_GAP, 1}, + color); + game.text.draw(game.renderer, label, {.color = widget_color(true), .position = {textX, row.y + TEXT_INSET}}); +} + +// A separator that folds what's under it away: an arrow (right when folded, down when open) before its label, which +// lights under the mouse. Returns whether it was clicked, to flip it. +bool Ui::fold_draw(Game& game, Rect row, const char* label, bool isOpen) +{ + bool isFocused = focus_add(label, 0, row); + bool isHovered = is_hovered(game, row) || isFocused; + bool isClicked = (is_hovered(game, row) && game.input.isClicked) || (isFocused && isConfirmed); + if (isClicked) + game.audio.play(SOUND_SELECT); + Vec2 size = game.text.size_get(label); + float width = FOLD_ARROW * 2 + SEPARATOR_GAP + size.x; + float startX = std::round(row.x + (row.w - width) / 2); + float textX = startX + FOLD_ARROW * 2 + SEPARATOR_GAP; + float lineY = std::round(row.y + row.h / 2); + Color line = widget_color(isHovered); + game.renderer.rect_draw({row.x, lineY, startX - SEPARATOR_GAP - row.x, 1}, line); + game.renderer.rect_draw({textX + size.x + SEPARATOR_GAP, lineY, row.x + row.w - textX - size.x - SEPARATOR_GAP, 1}, + line); + // The arrow, a pixel row (or column) at a time. + for (int i = 0; i < FOLD_ARROW; i++) + { + float length = (FOLD_ARROW - i) * 2 - 1; + if (isOpen) + game.renderer.rect_draw({startX + i, lineY - FOLD_ARROW / 2 + i, length, 1}, widget_color(true)); + else + game.renderer.rect_draw({startX + i, lineY - FOLD_ARROW + 1 + i, 1, length}, widget_color(true)); + } + game.text.draw(game.renderer, label, {.color = widget_color(true), .position = {textX, row.y + TEXT_INSET}}); + return isClicked; +} + +// A fighter walking on the spot at the right of an area, in a colour, its name over it and a caption under it. +void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color color, const char* caption) +{ + float centre = area.x + area.w - FIGHTER_PREVIEW_WIDTH / 2; + float feet = area.y + PREVIEW_FEET; + Vec2 nameSize = game.text.size_get(name); + game.text.draw(game.renderer, name, + {.color = color, .position = {std::round(centre - nameSize.x / 2), area.y + PREVIEW_NAME_Y}}); + Anm2& player = game.sprites.player; + Anm2Animation* walk = player.animation_get(PREVIEW_ANIMATION); + float frame = walk ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)walk->length) : 0; + player.draw(game.renderer, PREVIEW_ANIMATION, frame, + {.color = color, .position = {centre, feet}, .scale = {PREVIEW_SCALE, PREVIEW_SCALE}}); + if (!caption || !*caption) + return; + Vec2 captionSize = game.text.size_get(caption); + game.text.draw(game.renderer, caption, + {.color = shade, .position = {std::round(centre - captionSize.x / 2), feet + PREVIEW_CAPTION_GAP}}); +} + +// A connection's quality: signal bars (more lit the quicker it is, green to red) and its round trip in milliseconds, a +// line tall from its top left corner. Returns how wide it is. Unknown yet, it's the bars alone, unlit. +constexpr float PING_BAR_WIDTH = 2; +constexpr float PING_BAR_GAP = 1; +constexpr int PING_BARS = 4; +constexpr float PING_LIMITS[PING_BARS - 1] = {60, 120, 200}; +constexpr Color PING_COLORS[PING_BARS] = { + {0.9f, 0.25f, 0.2f, 1}, {0.95f, 0.6f, 0.2f, 1}, {0.9f, 0.85f, 0.25f, 1}, {0.35f, 0.85f, 0.35f, 1}}; + +static std::string ping_text_get(float ping) +{ + return ping < 0 ? std::string() : std::to_string((int)std::round(ping)) + "ms"; +} + +float Ui::ping_width_get(Game& game, float ping) +{ + std::string text = ping_text_get(ping); + float bars = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP; + return bars + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x); +} + +float Ui::ping_draw(Game& game, Vec2 corner, float ping, float alpha) +{ + int lit = 0; + if (ping >= 0) + { + lit = PING_BARS; + for (float limit : PING_LIMITS) + lit -= ping >= limit; + lit = std::max(lit, 1); + } + Color color = lit > 0 ? PING_COLORS[lit - 1] : shade; + float x = std::round(corner.x); + float bottom = std::round(corner.y + LINE_HEIGHT - 3); + for (int i = 0; i < PING_BARS; i++) + { + float height = 2 + i * 2; + game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height}, + color_fade(i < lit ? color : color_shade(shade, 0.5f), alpha)); + x += PING_BAR_WIDTH + PING_BAR_GAP; + } + std::string text = ping_text_get(ping); + if (!text.empty()) + game.text.draw(game.renderer, text.c_str(), + {.color = color_fade(color, alpha), .position = {x + PING_BAR_GAP, corner.y}}); + return ping_width_get(game, ping); +} + +// What a game's rules make for, in a few lines under a title with its mode's icon: what it's about, how many play, the +// slots, how items are handed out, and how it's won. The "game tooltip". +static std::vector game_summary_lines(const Rules& rules) +{ + std::vector lines; + bool isRace = rules.mode == MODE_RACE; + lines.push_back(isRace ? "- First to the finish wins!" : "- Fight to the death!"); + lines.push_back("- " + std::to_string(rules.players) + " players"); + auto count = [](int n, const char* one) { + return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); + }; + lines.push_back("- " + count(rules.weapons.slots, "weapon") + ", " + count(rules.trinkets.slots, "trinket")); + auto when = [](const ItemRules& items) { + if (items.timing == PICK_EVERY_ROUND) + return std::string("every round"); + if (items.timing == PICK_PERIOD) + return "every " + std::to_string(items.period) + " rounds"; + return std::string("on the first round"); + }; + auto handed = [&](const ItemRules& items, const char* one) { + std::string what = std::string(one) + (items.slots == 1 ? "" : "s"); + return (items.isChoice ? "Choose " + what : "Random " + what) + " " + when(items); + }; + const ItemRules& weapons = rules.weapons; + const ItemRules& trinkets = rules.trinkets; + if (weapons.isChoice == trinkets.isChoice && weapons.timing == trinkets.timing && + (weapons.timing != PICK_PERIOD || weapons.period == trinkets.period)) + lines.push_back(std::string("- ") + (weapons.isChoice ? "Choose" : "Random") + " weapon" + + (weapons.slots == 1 ? "" : "s") + " and trinket" + (trinkets.slots == 1 ? "" : "s") + " " + + when(weapons)); + else + { + lines.push_back("- " + handed(weapons, "weapon")); + lines.push_back("- " + handed(trinkets, "trinket")); + } + if (isRace) + lines.push_back(rules.timer > 0 ? "- " + std::to_string(rules.timer) + " minute time limit" + : "- No time limit"); + else + lines.push_back("- Best of " + std::to_string(rules.bestOf)); + return lines; +} + +Vec2 Ui::game_summary_size(Game& game, const Rules& rules) +{ + float width = ICON_SIZE + SUMMARY_ICON_GAP + game.text.size_get("Game: Deathmatch").x; + std::vector lines = game_summary_lines(rules); + for (std::string& line : lines) + width = std::max(width, game.text.size_get(line.c_str()).x); + return {width, ICON_SIZE + lines.size() * LINE_HEIGHT}; +} + +// The game's summary from its top left corner, returning how tall it is. +float Ui::game_summary_draw(Game& game, const Rules& rules, Vec2 corner, float alpha) +{ + bool isRace = rules.mode == MODE_RACE; + const char* mode = isRace ? "Race" : "Deathmatch"; + Color color = color_fade(tint, alpha); + game.sprites.modes.draw(game.renderer, mode, 0, + {.color = color, .position = {corner.x + ICON_SIZE / 2, corner.y + ICON_SIZE / 2}}); + std::string title = std::string("Game: ") + mode; + game.text.draw( + game.renderer, title.c_str(), + {.color = color, + .position = {corner.x + ICON_SIZE + SUMMARY_ICON_GAP, std::round(corner.y + (ICON_SIZE - LINE_HEIGHT) / 2)}}); + float y = corner.y + ICON_SIZE; + for (std::string& line : game_summary_lines(rules)) + { + game.text.draw(game.renderer, line.c_str(), {.color = color_fade(shade, alpha), .position = {corner.x, y}}); + y += LINE_HEIGHT; + } + return y - corner.y; +} + +// Starts laying rows into an area, scrolled by the wheel while the mouse is over it, or by moving the focus past its +// top or bottom row. +void Ui::page_begin(Game& game, Page& page, Rect area) +{ + page.area = area; + if (isWidgetLive && is_point_in_rect(game.input.mouse, area)) + page.scroll -= game.input.scroll * SCROLL_STEP; + float maxScroll = std::max(0.0f, page.height - area.h); + float before = std::clamp(page.scroll, 0.0f, maxScroll); + page.scroll = std::clamp(page.scroll + scroll_take(area) * SCROLL_STEP, 0.0f, maxScroll); + if (page.scroll != before) + scroll_mark(); + page.cursor = area.y; +} + +// Takes the next row of a page, giving where it sits and whether it's fully inside the area (only then drawn). +bool Ui::row_get(Page& page, float height, float& y) +{ + y = page.cursor - page.scroll; + page.cursor += height; + return y >= page.area.y && y + height <= page.area.y + page.area.h; +} + +// Ends a page, drawing a scroll bar down its right edge when its rows don't all fit. +void Ui::page_end(Game& game, Page& page) +{ + page.height = page.cursor - page.area.y; + if (page.height <= page.area.h) + return; + Rect track = {page.area.x + page.area.w - SCROLL_WIDTH, page.area.y, SCROLL_WIDTH, page.area.h}; + float head = track.h * page.area.h / page.height; + float headY = track.y + (track.h - head) * page.scroll / (page.height - page.area.h); + game.renderer.rect_draw(track, widget_color(false)); + game.renderer.rect_draw({track.x, std::round(headY), track.w, std::round(head)}, widget_color(true)); +} + +// Lets go of whatever widget was dragging or typing, giving the keyboard back to the game. +void Ui::widget_release(Game& game) +{ + active = nullptr; + SDL_StopTextInput(game.window); +} + +// Fades a cooldown bar in while it has cooldown left and out once it hasn't, keeping its last fill while it goes. +static void bar_fade(CooldownBar& bar, float left) +{ + if (left > 0) + bar.fill = left; + else if (bar.fade <= 0) + bar.fill = 0; + bar.fade = std::clamp(bar.fade + (left > 0 ? TICK / COOLDOWN_FADE_IN : -TICK / COOLDOWN_FADE_OUT), 0.0f, 1.0f); +} + +// Gives every showing bar of one kind (one per slot) a row, in slot order. Rows ease toward their places, so bars +// slide rather than jump as others come and go. +static void bars_arrange(CooldownBar* bars) +{ + int row = 0; + for (int slot = 0; slot < SLOT_MAX; slot++) + { + CooldownBar& bar = bars[slot]; + if (bar.fade <= 0) + bar.row = -1; + else if (bar.row < 0) + bar.row = row++; + else + bar.row += (row++ - bar.row) * std::min(1.0f, COOLDOWN_SLIDE * TICK); + } +} + +// Keeps the HUD's timers and the cooldown bars up to date. +void Ui::update(Game& game) +{ + isMenuSeen |= game.menu.isOpen; + if (isMenuSeen) + hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE); + spectate_update(game); + + // The HUD's hotbar slots and the other fighters' windows brighten while the mouse is over them, and dim again + // after. + Vec2 mouse = game.input.mouse; + auto hover_step = [&](float& hover, bool isHovered) { + hover = std::clamp(hover + (isHovered ? 1 : -1) * TICK / HUD_HOVER_FADE, 0.0f, 1.0f); + }; + Fighter& local = game.local_get(); + for (int kind = 0; kind < 2; kind++) + { + int count = kind == 1 ? local.trinketSlots : local.weaponSlots; + for (int slot = 0; slot < SLOT_MAX; slot++) + hover_step(slotHovers[kind][slot], + slot < count && is_point_in_rect(mouse, game.menu.slot_rect(kind == 1, slot, count))); + } + RivalLayout rivals = rivals_layout(game); + for (int i = 0; i < MAX_FIGHTERS; i++) + { + int k = std::find(rivals.fighters, rivals.fighters + rivals.count, i) - rivals.fighters; + hover_step(rivalHovers[i], k < rivals.count && is_point_in_rect(mouse, rivals.cards[k])); + } + + // Every slot cools on its own bar: weapons whose cooldown is long enough to notice (copies that don't stack share + // the first one's), and trinkets with a cooldown of their own (one bar for all copies). A bar keeps its item's + // icon while it fades out after the item goes. + Fighter& fighter = game.local_get(); + float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); + for (int slot = 0; slot < SLOT_MAX; slot++) + { + float left = 0; + if (slot < fighter.weaponSlots && fighter.weapon_key(slot) == slot) + { + float total = fighter.level_get(slot).cooldown / gloves; + left = total >= COOLDOWN_VISIBLE ? fighter.cooldowns[slot] / total : 0; + weaponBars[slot].item = left > 0 ? fighter.weaponLoadout[slot] : weaponBars[slot].item; + } + bar_fade(weaponBars[slot], left); + + int trinket = fighter.trinketLoadout[slot]; + left = 0; + if (slot < fighter.trinketSlots && trinket >= 0 && + std::find(fighter.trinketLoadout, fighter.trinketLoadout + slot, trinket) == fighter.trinketLoadout + slot) + { + left = fighter.trinket_cooldown_get(trinket); + trinketBars[slot].item = left > 0 ? trinket : trinketBars[slot].item; + } + bar_fade(trinketBars[slot], left); + } + bars_arrange(weaponBars); + bars_arrange(trinketBars); +} + +// Every item cooling down shows at once, each on its own bar two slots long (whatever the hotbar's size) with its icon. +// Every bar cooling down, a row each: weapons downward from under the weapon hotbar, trinkets upward from over the +// trinket hotbar, each starting far enough off its hotbar that the icons (taller than the bars) clear its slots. Past a +// handful of one kind at once, its rows tighten and their icons shrink to half size so a full loadout still fits. +void Ui::cooldowns_draw(Game& game) +{ + float width = COOLDOWN_SLOTS * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP; + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + CooldownBar* bars = isTrinket ? trinketBars : weaponBars; + int showing = 0; + for (int slot = 0; slot < SLOT_MAX; slot++) + showing += bars[slot].fade > 0; + bool isCompact = showing > COOLDOWN_FULL_ROWS; + float spacing = isCompact ? COOLDOWN_COMPACT_SPACING : COOLDOWN_SPACING; + float iconScale = isCompact ? MINI_ICON_SCALE : 1; + float clearance = std::max(0.0f, (ICON_SIZE * iconScale - COOLDOWN_BAR_HEIGHT) / 2); + for (int slot = 0; slot < SLOT_MAX; slot++) + { + CooldownBar& bar = bars[slot]; + if (bar.fade <= 0 || bar.row < 0 || bar.item < 0) + continue; + float step = std::round(bar.row * spacing); + float y = isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE - COOLDOWN_GAP - COOLDOWN_BAR_HEIGHT - + std::ceil(clearance) - step + : HOTBAR_MARGIN + SLOT_SIZE + COOLDOWN_GAP + std::ceil(clearance) + step; + cooldown_bar_draw(game, {HOTBAR_MARGIN, y, width, COOLDOWN_BAR_HEIGHT}, bar, isTrinket, bar.item, + iconScale); + } + } +} + +// A bordered bar draining with the cooldown and its item's icon just right of it, at full size so its pixels stay +// whole. Both fade with the bar. +void Ui::cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale) +{ + Color color = color_fade(tint, bar.fade); + bar_draw(game.renderer, rect, bar.fill, color); + float icon = ICON_SIZE * iconScale; + icon_draw(game, isTrinket, item, + {.color = color, + .position = {std::round(rect.x + rect.w + COOLDOWN_GAP + icon / 2), rect.y + rect.h / 2}, + .scale = {iconScale, iconScale}}); +} + +// A weapon's or trinket's HUD icon, centred on the placement. +void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size) +{ + Sprites& sprites = game.sprites; + Anm2* sheets[2][ICON_SIZE_COUNT] = {{&sprites.icons, &sprites.iconsSmall, &sprites.iconsTiny}, + {&sprites.trinkets, &sprites.trinketsSmall, &sprites.trinketsTiny}}; + Anm2& art = *sheets[isTrinket][size]; + art.draw(game.renderer, isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, 0, placement); +} + +// An arrow at the screen's edge for every other living fighter out of view, pointing toward it, in its colour, with +// its name just inside. +void Ui::offscreen_draw(Game& game, Vec2 camera) +{ + Vec2 half = {CANVAS_WIDTH / 2.0f, CANVAS_HEIGHT / 2.0f}; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + Vec2 offset = fighter.center_get() - camera - half; + bool isVisible = std::abs(offset.x) < half.x && std::abs(offset.y) < half.y; + if (i == game.local || !fighter.isAlive || isVisible) + continue; + + float reach = std::min((half.x - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.x)), + (half.y - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.y))); + Vec2 edge = half + offset * reach; + Color color = game.color_get(i); + game.sprites.ui.draw(game.renderer, "Arrow", 0, + {.color = color, + .position = edge, + .scale = {OFFSCREEN_ARROW_SCALE, OFFSCREEN_ARROW_SCALE}, + .angle = vec2_angle(offset) + PI / 2}); + + const char* name = name_get(game, i); + Vec2 size = game.text.size_get(name); + Vec2 label = edge - vec2_normalize(offset) * (OFFSCREEN_NAME_GAP + size.x / 2) - size * 0.5f; + label.x = std::clamp(label.x, 0.0f, CANVAS_WIDTH - size.x); + label.y = std::clamp(label.y, 0.0f, CANVAS_HEIGHT - size.y); + game.text.draw(game.renderer, name, {.color = color, .position = {std::round(label.x), std::round(label.y)}}); + } +} + +// Each of this screen's fighters' crosshairs in its colour: at the mouse for the keyboard's (and whenever the menu is +// open), otherwise where its stick aims. With none (a bot playing), the mouse's. +void Ui::crosshairs_draw(Game& game, Vec2 camera) +{ + Input& input = game.input; + bool isAny = false; + for (int i = 0; i < game.fighterCount; i++) + { + if (!game.is_local(i)) + continue; + isAny = true; + bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) || + (i == game.local && game.menu.isOpen); + Vec2 aim = isMouse ? input.mouse : game.fighters[i].controls.cursor - camera; + game.sprites.ui.draw(game.renderer, "Crosshair", 0, + {.color = game.color_get(i), .position = {std::floor(aim.x), std::floor(aim.y)}}); + } + if (!isAny) + game.sprites.ui.draw(game.renderer, "Crosshair", 0, + {.color = tint, .position = {std::floor(input.mouse.x), std::floor(input.mouse.y)}}); +} + +// With several players on this screen in a match, a compact panel each along the bottom in their colour: name and +// health, then rows of small weapon and trinket slots, the held weapon lit. +void Ui::panels_draw(Game& game) +{ + int players[MAX_FIGHTERS]; + int count = 0; + for (int i = 0; i < game.fighterCount; i++) + if (game.is_local(i)) + players[count++] = i; + float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (count - 1)) / count; + float top = CANVAS_HEIGHT - HOTBAR_MARGIN - PLAYER_PANEL_HEIGHT; + for (int k = 0; k < count; k++) + { + int index = players[k]; + Fighter& fighter = game.fighters[index]; + Color color = game.color_get(index); + Color dim = color_shade(color, SECONDARY_SHADE); + Rect panel = {std::round(HOTBAR_MARGIN + k * (width + LOADOUT_GAP)), top, std::floor(width), + PLAYER_PANEL_HEIGHT}; + panel_draw(game.renderer, panel, color); + float x = panel.x + PICK_PADDING; + float y = panel.y + PICK_PADDING; + const char* name = name_get(game, index); + game.text.draw(game.renderer, name, {.color = color, .position = {x, y}}); + if (!game.match.is_racing()) + { + float barX = x + game.text.size_get(name).x + PICK_PADDING; + Rect bar = {barX, y + 2, panel.x + panel.w - PICK_PADDING - barX, HEALTH_BAR_HEIGHT}; + bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.match.health_get(), color); + } + int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP; + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + int slots = std::min(fit, isTrinket ? fighter.trinketSlots : fighter.weaponSlots); + float rowY = y + LINE_HEIGHT + kind * MINI_STEP; + for (int slot = 0; slot < slots; slot++) + { + int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[slot]; + bool isLit = isTrinket ? item >= 0 : slot == fighter.slot; + Rect box = {x + slot * MINI_STEP, rowY, MINI_SLOT, MINI_SLOT}; + panel_draw(game.renderer, box, isLit ? color : dim); + if (item >= 0) + icon_draw(game, isTrinket, item, + {.color = isLit ? color : dim, + .position = {box.x + box.w / 2, box.y + box.h / 2}, + .scale = {MINI_ICON_SCALE, MINI_ICON_SCALE}}); + } + } + } +} + +// Whether this screen is watching others: its only fighter is out of a round still being played (dead in a +// deathmatch, or over the line in a race) and someone else is in it. +static bool is_spectating(Game& game) +{ + MatchState state = (MatchState)game.match.state; + // Watching a queued game from the lobby, it's whoever's still in it. + if (game.session.is_watching()) + { + for (int i = 0; i < game.fighterCount; i++) + if (game.fighters[i].isAlive) + return true; + return false; + } + if ((state != MATCH_FIGHT && state != MATCH_ROUND_OVER) || game.local_count() != 1 || game.local_get().isAlive) + return false; + for (int i = 0; i < game.fighterCount; i++) + if (i != game.local && game.fighters[i].isAlive) + return true; + return false; +} + +// Picks who to watch while spectating. It starts on whoever took this screen's fighter out, and moves on to whoever +// takes the watched one out (or the next one alive). Left and Right step back and forth through the living, as does +// Attack (forward). Presses are caught here, against last tick's state, since the tick's own edges are gone by now. +void Ui::spectate_update(Game& game) +{ + Input& input = game.input; + bool isWatching = game.session.is_watching(); + int device = isWatching ? DEVICE_KEYBOARD : game.devices[game.local]; + bool held[3] = {}; + if (device != DEVICE_NONE) + for (int i = 0; i < 3; i++) + held[i] = input.is_control_down(device, SPECTATE_CONTROLS[i]); + bool isPressed[3]; + for (int i = 0; i < 3; i++) + { + isPressed[i] = held[i] && !spectateHeld[i]; + spectateHeld[i] = held[i]; + } + if (!is_spectating(game)) + { + spectated = -1; + return; + } + + auto is_watchable = [&](int fighter) { + return fighter >= 0 && fighter < game.fighterCount && (fighter != game.local || isWatching) && + game.fighters[fighter].isAlive; + }; + auto next_get = [&](int from, int step) { + for (int k = 1; k <= game.fighterCount; k++) + { + int fighter = ((from + step * k) % game.fighterCount + game.fighterCount) % game.fighterCount; + if (is_watchable(fighter)) + return fighter; + } + return from; + }; + if (!is_watchable(spectated)) + { + int taker = spectated >= 0 ? game.fighters[spectated].launcher : game.local_get().launcher; + spectated = is_watchable(taker) ? taker : next_get(spectated >= 0 ? spectated : game.local, 1); + return; + } + int step = (isPressed[1] || isPressed[2]) - isPressed[0]; + if (step != 0 && !game.menu.isOpen) + { + spectated = next_get(spectated, step); + game.audio.play(SOUND_SELECT); + } +} + +// While spectating, who's being watched, in their colour, near the bottom, with the controls that switch. +void Ui::spectate_draw(Game& game) +{ + if (spectated < 0) + return; + Input& input = game.input; + bool isPad = input.devices[DEVICE_KEYBOARD].isAiming; + char text[TOOLTIP_LENGTH]; + SDL_snprintf(text, sizeof(text), "Spectating %s", name_get(game, spectated)); + float y = CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT * 2 - SPECTATE_GAP; + text_center_draw(game, text, y, 1, game.color_get(spectated, true)); + char keys[2][TOOLTIP_LENGTH]; + for (int i = 0; i < 2; i++) + { + Binding binding = input.bindings[i == 0 ? CONTROL_LEFT : CONTROL_RIGHT]; + input.binding_name_get(isPad ? binding.pad : binding.key, isPad, keys[i], sizeof(keys[i])); + } + SDL_snprintf(text, sizeof(text), "%s / %s: Switch%s", keys[0], keys[1], + game.session.is_watching() ? " Esc: Back to Lobby" : ""); + text_center_draw(game, text, y + LINE_HEIGHT, 1, shade); +} + +// Lays out the other fighters' windows in a row along the bottom right, never left of the screen's middle (nor over the +// local hotbar and cooldowns). A window is as wide as its icon row, but no wider than one holding two weapons and two +// trinkets at full size: past that, or when the row doesn't fit, icons shrink to small, then tiny. Each kind wraps onto +// more rows past three across, and tiny icons wrap further if they still don't fit. Names and health use the small +// font, or the tiny one for every window if any doesn't fit. +RivalLayout Ui::rivals_layout(Game& game) +{ + RivalLayout layout; + Fighter& local = game.local_get(); + int slots[2] = {}; + for (int i = 0; i < game.fighterCount; i++) + { + if (i == game.local && !game.session.is_watching()) + continue; + layout.fighters[layout.count++] = i; + slots[0] = std::max(slots[0], game.fighters[i].weaponSlots); + slots[1] = std::max(slots[1], game.fighters[i].trinketSlots); + } + if (layout.count == 0) + return layout; + + float step = SLOT_SIZE + SLOT_GAP; + float left = + std::max(CANVAS_WIDTH / 2.0f, + HOTBAR_MARGIN + std::max(local.trinketSlots * step, COOLDOWN_SLOTS * step + ICON_SIZE) + RIVAL_GAP); + float room = CANVAS_WIDTH - HOTBAR_MARGIN - left; + float inset = PANEL_BORDER + RIVAL_PADDING; + auto icons_width = [&](int size, int columns) { + return columns * (ICON_PIXELS[size] + RIVAL_ICON_GAP) + RIVAL_DIVIDER - RIVAL_ICON_GAP; + }; + float widthMax = icons_width(ICON_FULL, RIVAL_FULL_SLOTS) + inset * 2; + char health[TOOLTIP_LENGTH]; + SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.match.health_get())); + float minimum = game.text.size_get(health, FONT_TINY).x + RIVAL_PADDING + RIVAL_BAR_MIN + inset * 2; + float width = 0; + for (int size = ICON_FULL; size < ICON_SIZE_COUNT; size++) + { + // No more than a few of a kind side by side, wrapping onto more rows to keep to that; tiny icons can wrap + // further to fit. + int rowsMin = (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX; + int rowsMax = size == ICON_TINY ? SLOT_MAX : rowsMin; + for (int rows = std::max(1, rowsMin); rows <= rowsMax; rows++) + { + for (int kind = 0; kind < 2; kind++) + layout.columns[kind] = (slots[kind] + rows - 1) / rows; + float icons = icons_width(size, layout.columns[0] + layout.columns[1]) + inset * 2; + width = std::ceil(std::max(icons, minimum)); + layout.size = (IconSize)size; + layout.rows = rows; + bool isFit = icons <= widthMax && layout.count * width + (layout.count - 1) * RIVAL_GAP <= room; + if (isFit) + size = ICON_SIZE_COUNT; + if (size == ICON_SIZE_COUNT) + break; + } + } + + float inner = width - inset * 2; + layout.font = game.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner + ? FONT_SMALL + : FONT_TINY; + for (int k = 0; k < layout.count; k++) + if (game.text.size_get(name_get(game, layout.fighters[k])).x > inner) + layout.font = FONT_TINY; + layout.line = game.text.line_height_get(layout.font); + + float healthHeight = game.match.is_racing() ? 0 : layout.line + RIVAL_PADDING; + float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP; + float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset; + float x = CANVAS_WIDTH - HOTBAR_MARGIN - layout.count * width - (layout.count - 1) * RIVAL_GAP; + for (int k = 0; k < layout.count; k++) + layout.cards[k] = {std::round(x + k * (width + RIVAL_GAP)), CANVAS_HEIGHT - HOTBAR_MARGIN - height, width, + height}; + return layout; +} + +// Every other fighter's window in a match, bordered in its colour: its name centred, its health with a bar beside it, +// then its weapons, a divider, and its trinkets, the weapon in hand lit. A window is dimmed (a fighter out of the round +// more so) until the mouse comes over it, when it eases to full brightness. Hovering an +// icon shows the item's tooltip, as that fighter has it, in its colour. +void Ui::rivals_draw(Game& game) +{ + Match& match = game.match; + if (match.state == MATCH_PRACTICE || match.state == MATCH_COUNTDOWN) + return; + Renderer& renderer = game.renderer; + RivalLayout layout = rivals_layout(game); + float inset = PANEL_BORDER + RIVAL_PADDING; + float iconSize = ICON_PIXELS[layout.size]; + float cell = iconSize + RIVAL_ICON_GAP; + int hoveredFighter = -1; + int hoveredKind = 0; + int hoveredSlot = 0; + for (int k = 0; k < layout.count; k++) + { + int index = layout.fighters[k]; + Fighter& fighter = game.fighters[index]; + Rect card = layout.cards[k]; + float idle = fighter.isAlive || match.is_racing() ? HUD_IDLE_ALPHA : RIVAL_OUT_ALPHA; + float alpha = index == spectated ? 1 : idle + (1 - idle) * rivalHovers[index]; + // Faded by darkening toward the solid black fill, so the window stays opaque and nothing in it shows through + // anything else. + Color color = color_shade(game.color_get(index), alpha); + Color dim = color_shade(color, SECONDARY_SHADE); + panel_draw(renderer, card, color, 1); + + float y = card.y + inset; + Font font = layout.font; + const char* name = name_get(game, index); + Vec2 size = game.text.size_get(name, font); + game.text.draw( + renderer, name, + {.color = color, + .position = {std::round(card.x + (card.w - size.x) / 2), std::round(y + (layout.line - size.y) / 2)}}, + font); + y += layout.line + RIVAL_PADDING; + + if (!match.is_racing()) + { + char text[TOOLTIP_LENGTH]; + SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); + size = game.text.size_get(text, font); + float x = card.x + inset; + game.text.draw(renderer, text, + {.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font); + x += size.x + RIVAL_PADDING; + float barHeight = std::min(HEALTH_BAR_HEIGHT, layout.line - 1); + Rect bar = {x, std::round(y + (layout.line - barHeight) / 2), card.x + card.w - inset - x, barHeight}; + bar_draw(renderer, bar, std::clamp(fighter.health / match.health_get(), 0.0f, 1.0f), color); + y += layout.line + RIVAL_PADDING; + } + + // Weapons then trinkets, each in its columns, centred with the divider between. + float iconsWidth = (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP; + float x = std::round(card.x + (card.w - iconsWidth) / 2); + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + for (int slot = 0; slot < slots; slot++) + { + Rect box = {x + slot % layout.columns[kind] * cell, y + slot / layout.columns[kind] * cell, iconSize, + iconSize}; + int item = loadout[slot]; + if (item < 0) + continue; + bool isLit = isTrinket || slot == fighter.slot; + icon_draw(game, isTrinket, item, + {.color = isLit ? color : dim, .position = {box.x + box.w / 2, box.y + box.h / 2}}, + layout.size); + if (is_point_in_rect(game.input.mouse, box)) + { + hoveredFighter = index; + hoveredKind = kind; + hoveredSlot = slot; + } + } + x += layout.columns[kind] * cell; + if (kind == 0) + { + renderer.rect_draw({x, y, RIVAL_DIVIDER, layout.rows * cell - RIVAL_ICON_GAP}, dim); + x += RIVAL_DIVIDER + RIVAL_ICON_GAP; + } + } + } + + if (hoveredFighter < 0 || game.menu.isOpen) + return; + Fighter& fighter = game.fighters[hoveredFighter]; + bool isTrinket = hoveredKind == 1; + int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot]; + int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot]; + Tooltip tooltip = tooltip_build(game, isTrinket, item, level, hoveredFighter); + // The tooltip takes that fighter's colour for the moment, so it's clear whose item it is. + Color colors[2] = {tint, shade}; + tint = game.color_get(hoveredFighter); + shade = color_shade(tint, SECONDARY_SHADE); + tooltip_draw(game, tooltip); + tint = colors[0]; + shade = colors[1]; +} + +// Choosing items with several players on this screen: a panel each below top (side by side for two, a 2x2 grid for +// more). +void Ui::picks_draw(Game& game, float top) +{ + int players[MAX_FIGHTERS]; + int count = 0; + for (int i = 0; i < game.fighterCount; i++) + if (game.is_local(i)) + players[count++] = i; + int columns = std::min(count, 2); + int rows = (count + 1) / 2; + float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (columns - 1)) / columns; + float height = (CANVAS_HEIGHT - HOTBAR_MARGIN - top - LOADOUT_GAP * (rows - 1)) / rows; + for (int k = 0; k < count; k++) + pick_panel_draw(game, players[k], + {std::round(HOTBAR_MARGIN + k % columns * (width + LOADOUT_GAP)), + std::round(top + k / columns * (height + LOADOUT_GAP)), std::floor(width), + std::floor(height)}); +} + +// One player's choosing panel in their colour: their name, which kind they're choosing (bumpers switch when both +// are), the highlighted item's name, whether they're ready, the items to choose from and the slots they fill. On a +// gamepad the D-pad or stick moves the highlight, A puts the item in the first empty slot (or the last) and B empties +// the last filled slot. The keyboard's player can also click items in, click a slot to fill it, right-click to empty +// it, or press a slot's number. +void Ui::pick_panel_draw(Game& game, int index, Rect panel) +{ + Match& match = game.match; + Input& input = game.input; + Fighter& fighter = game.fighters[index]; + int device = game.devices[index]; + bool isKeyboard = device == DEVICE_KEYBOARD; + bool& isTrinket = isPickTrinkets[index]; + isTrinket = match.isTrinketPick && (isTrinket || !match.isWeaponPick); + bool isBoth = match.isWeaponPick && match.isTrinketPick; + if (isBoth && (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) || + input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER))) + isTrinket = !isTrinket; + + Color previousTint = tint; + Color previousShade = shade; + tint = game.color_get(index); + shade = color_shade(tint, SECONDARY_SHADE); + panel_draw(game.renderer, panel, tint); + + int items = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + float step = SLOT_SIZE + SLOT_GAP; + int columns = std::max(1, (int)((panel.w - PICK_PADDING * 2 + SLOT_GAP) / step)); + int& cursor = pickCursors[index]; + int nav = input.nav_get(device); + cursor += (nav & NAV_RIGHT ? 1 : 0) - (nav & NAV_LEFT ? 1 : 0); + cursor += ((nav & NAV_DOWN ? 1 : 0) - (nav & NAV_UP ? 1 : 0)) * columns; + cursor = std::clamp(cursor, 0, items - 1); + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + + float x = panel.x + PICK_PADDING; + float y = panel.y + PICK_PADDING; + char text[TOOLTIP_LENGTH]; + const char* kind = + isBoth ? (isTrinket ? "Trinkets (Bumpers)" : "Weapons (Bumpers)") : (isTrinket ? "Trinkets" : "Weapons"); + const char* item = isTrinket ? TRINKET_DEFS[cursor].name : WEAPON_DEFS[cursor].name; + SDL_snprintf(text, sizeof(text), "%s - %s: %s", name_get(game, index), kind, item); + game.text.draw(game.renderer, text, {.color = tint, .position = {x, y}}); + const char* ready = match.isReady[index] ? "Ready!" : "Not Ready"; + float readyWidth = game.text.size_get(ready).x; + game.text.draw( + game.renderer, ready, + {.color = match.isReady[index] ? tint : shade, .position = {panel.x + panel.w - PICK_PADDING - readyWidth, y}}); + y += LINE_HEIGHT + PICK_PADDING; + + for (int i = 0; i < items; i++) + { + Rect box = {x + i % columns * step, y + i / columns * step, SLOT_SIZE, SLOT_SIZE}; + if (isKeyboard && is_point_in_rect(input.mouse, box)) + { + cursor = i; + if (input.isClicked) + { + int empty = std::find(loadout, loadout + slots, -1) - loadout; + pick_send(game, index, isTrinket, std::min(empty, slots - 1), i); + } + } + slot_draw(game, + {.rect = box, .item = i, .isHoverable = isKeyboard, .isLit = i == cursor, .isTrinket = isTrinket}); + } + y += (items + columns - 1) / columns * step + PICK_PADDING; + + for (int slot = 0; slot < slots; slot++) + { + Rect box = {x + slot * step, y, SLOT_SIZE, SLOT_SIZE}; + bool isHovered = isKeyboard && is_point_in_rect(input.mouse, box); + if (isHovered && input.isClicked) + pick_send(game, index, isTrinket, slot, cursor); + if (isHovered && input.isRightClicked) + pick_send(game, index, isTrinket, slot, -1); + slot_draw(game, {.rect = box, + .item = loadout[slot], + .label = slot + 1, + .isHoverable = isKeyboard, + .isLit = loadout[slot] >= 0, + .isTrinket = isTrinket}); + } + if (isKeyboard && input.slotPressed >= 0 && input.slotPressed < slots) + pick_send(game, index, isTrinket, input.slotPressed, cursor); + if (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_SOUTH)) + { + int empty = std::find(loadout, loadout + slots, -1) - loadout; + pick_send(game, index, isTrinket, std::min(empty, slots - 1), cursor); + } + int filled = slots - 1; + while (filled >= 0 && loadout[filled] < 0) + filled--; + if (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_EAST) && filled >= 0) + pick_send(game, index, isTrinket, filled, -1); + + tint = previousTint; + shade = previousShade; +} + +// Sends one player's pick through their controls on the next tick, with its sound. +void Ui::pick_send(Game& game, int fighter, bool isTrinket, int slot, int item) +{ + game.input.edit_send(game.devices[fighter], pick_encode(isTrinket, slot, item)); + game.audio.play(item < 0 ? SOUND_UNEQUIP : SOUND_EQUIP); +} + +// Starts a frame of focusable widgets. Last frame's widgets are where the focus can move: the nearest one in the +// direction pressed (a focused number or slider takes left and right instead). Up or down with nothing that way asks +// the page or grid the focus is in to scroll (see scroll_take), and tries again next frame once it has. +void Ui::focus_begin(Game& game) +{ + Input& input = game.input; + FocusTarget* last = targets[targetFrame]; + int lastCount = targetCounts[targetFrame]; + targetFrame ^= 1; + targetCounts[targetFrame] = 0; + isFocusing = true; + + int nav = input.nav_get(DEVICE_KEYBOARD) | navRetry; + bool isCapturing = input.captureControl >= 0 || active; + if (isCapturing) + nav = 0; + navRetry = 0; + scrollWant = 0; + isScrolled = false; + adjust = 0; + if (input.isMouseMoved || input.isClicked || input.isRightClicked || input.scroll != 0) + isFocusShown = false; + if (nav || input.isConfirmed || input.pad_presses_get(DEVICE_KEYBOARD)) + isFocusShown = true; + isConfirmed = isFocusShown && !isCapturing && + (input.isConfirmed || input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_SOUTH)); + if (!isFocusShown || nav == 0) + return; + + FocusTarget* from = nullptr; + for (int i = 0; i < lastCount; i++) + if (last[i].id == focusId && last[i].sub == focusSub) + from = &last[i]; + if (!from) + return; + int across = NAV_LEFT | NAV_RIGHT; + if (from->isAdjustable && (nav & across)) + { + adjust = (nav & NAV_RIGHT ? 1 : 0) - (nav & NAV_LEFT ? 1 : 0); + nav &= ~across; + } + + Rect a = from->rect; + for (int direction : {NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT}) + { + if (!(nav & direction)) + continue; + bool isVertical = direction == NAV_UP || direction == NAV_DOWN; + // The gap to each widget that way, and how far it sits off to the side: up and down take the nearest row, + // then the widget in it most in line; left and right only move along the same row. + int best = -1; + float bestAhead = 0; + float bestAside = 0; + for (int i = 0; i < lastCount; i++) + { + Rect b = last[i].rect; + if (&last[i] == from) + continue; + float ahead = direction == NAV_UP ? a.y - (b.y + b.h) + : direction == NAV_DOWN ? b.y - (a.y + a.h) + : direction == NAV_LEFT ? a.x - (b.x + b.w) + : b.x - (a.x + a.w); + float centre = isVertical ? (b.y + b.h / 2) - (a.y + a.h / 2) : (b.x + b.w / 2) - (a.x + a.w / 2); + bool isAhead = (direction == NAV_UP || direction == NAV_LEFT) ? centre < -1 : centre > 1; + float aside = isVertical ? std::max({0.0f, b.x - (a.x + a.w), a.x - (b.x + b.w)}) + : std::max({0.0f, b.y - (a.y + a.h), a.y - (b.y + b.h)}); + if (!isAhead || (!isVertical && aside > 0)) + continue; + ahead = std::max(0.0f, ahead); + aside += std::abs(isVertical ? b.x - a.x : 0) * FOCUS_ALIGN_WEIGHT; + bool isNearerRow = best < 0 || ahead < bestAhead - FOCUS_ROW_SLACK; + bool isSameRow = best >= 0 && std::abs(ahead - bestAhead) <= FOCUS_ROW_SLACK; + if (isNearerRow || (isSameRow && aside < bestAside)) + { + best = i; + bestAhead = ahead; + bestAside = aside; + } + } + if (best >= 0) + { + focusId = last[best].id; + focusSub = last[best].sub; + game.audio.play(SOUND_SELECT); + return; + } + if (isVertical) + scrollWant = direction; + return; + } +} + +// Ends the frame's focusable widgets, landing the focus on one if it's on none of them (the preferred one, or the +// first), and marking its corners. +void Ui::focus_end(Game& game) +{ + isFocusing = false; + if (scrollWant && isScrolled) + navRetry = scrollWant; + FocusTarget* now = targets[targetFrame]; + int count = targetCounts[targetFrame]; + if (!isFocusShown || count == 0) + return; + FocusTarget* focused = nullptr; + for (int i = 0; i < count; i++) + if (now[i].id == focusId && now[i].sub == focusSub) + focused = &now[i]; + if (!focused) + { + focused = &now[0]; + for (int i = count - 1; i >= 0; i--) + if (now[i].isPreferred) + focused = &now[i]; + focusId = focused->id; + focusSub = focused->sub; + } + // A bracket at each corner, just outside it. + Rect rect = focused->rect; + float left = rect.x - FOCUS_OUTSET; + float top = rect.y - FOCUS_OUTSET; + float right = rect.x + rect.w + FOCUS_OUTSET - 1; + float bottom = rect.y + rect.h + FOCUS_OUTSET - 1; + for (float x : {left, right}) + for (float y : {top, bottom}) + { + float armX = x == left ? x : x - FOCUS_ARM + 1; + float armY = y == top ? y : y - FOCUS_ARM + 1; + game.renderer.rect_draw({armX, y, FOCUS_ARM, 1}, tint); + game.renderer.rect_draw({x, armY, 1, FOCUS_ARM}, tint); + } +} + +// Adds a widget the focus can move to, returning whether it has the focus. Disabled widgets, and any outside focus +// begin and end, can't. +bool Ui::focus_add(const void* id, int sub, Rect rect, bool isAdjustable, bool isPreferred) +{ + int& count = targetCounts[targetFrame]; + if (!isFocusing || !isWidgetLive || !isWidgetEnabled || count >= FOCUS_TARGETS_MAX) + return false; + targets[targetFrame][count++] = {rect, id, sub, isAdjustable, isPreferred}; + bool isFocused = isFocusShown && id == focusId && sub == focusSub; + if (isFocused) + focusRect = rect; + return isFocused; +} + +bool Ui::is_focused(const void* id, int sub) +{ + return isFocusing && isFocusShown && id == focusId && sub == focusSub; +} + +// Which way (-1 up, 1 down) a page or grid in area should scroll for the focus, if it's in there and wants to move +// past the rows shown. Whatever scrolls says so with scroll_mark, so the move is tried again. +int Ui::scroll_take(Rect area) +{ + Vec2 centre = {focusRect.x + focusRect.w / 2, focusRect.y + focusRect.h / 2}; + if (!scrollWant || !isFocusShown || !is_point_in_rect(centre, area)) + return 0; + return scrollWant == NAV_UP ? -1 : 1; +} + +void Ui::scroll_mark() +{ + isScrolled = true; +} + +// Online, in the lobby's sandbox, each queued game: its area on the ground in the queuer's colour (a mark on the floor +// for each place in it, lit once taken), the game's mode icon and a "!" bobbing over the queuer, and under the area, +// how many have readied up and (standing in it) the key to ready up. Hovering the queuer or its icon shows the game. +void Ui::queues_draw(Game& game) +{ + hoveredQueue = -1; + Match& match = game.match; + if (!game.session.isActive || match.state != MATCH_PRACTICE) + return; + Renderer& renderer = game.renderer; + Input& input = game.input; + Vec2 mouse = input.mouse + renderer.camera; + float bob = std::round(math_sin(SDL_GetTicks() / 1000.0f * QUEUE_ICON_SPEED) * QUEUE_ICON_BOB); + for (int q = 0; q < game.fighterCount; q++) + { + Queue& queue = match.queues[q]; + if (!queue.isActive || match.isAway[q]) + continue; + Color color = game.color_get(q, true); + Rect area = queue.area; + int members[MAX_FIGHTERS]; + int count = match.queue_members_get(game, q, members); + renderer.rect_draw(area, color_fade(color, QUEUE_FILL_ALPHA)); + Color edge = color_fade(color, QUEUE_EDGE_ALPHA); + renderer.rect_draw({area.x, area.y, 1, area.h}, edge); + renderer.rect_draw({area.x + area.w - 1, area.y, 1, area.h}, edge); + renderer.rect_draw({area.x, area.y, area.w, 1}, edge); + for (int k = 0; k < queue.rules.players; k++) + { + float x = std::round(area.x + QUEUE_MARGIN + k * QUEUE_SLOT_WIDTH + 2); + renderer.rect_draw({x, area.y + area.h - 2, QUEUE_SLOT_WIDTH - 4, 2}, + k < count ? color : color_shade(color, SECONDARY_SHADE)); + } + + Fighter& queuer = game.fighters[q]; + Rect box = queuer.body.box; + Vec2 icon = {std::round(box.x + box.w / 2), std::round(box.y - QUEUE_ICON_RISE + bob)}; + float bang = game.text.size_get("!", FONT_SMALL, 2).x; + icon.x -= std::round(bang / 2); + game.sprites.modes.draw(renderer, "Deathmatch", 0, {.color = color, .position = icon}); + game.text.draw(renderer, "!", + {.color = color, .position = {icon.x + ICON_SIZE / 2, icon.y - ICON_SIZE / 2}, .scale = {2, 2}}); + Rect iconRect = {icon.x - ICON_SIZE / 2, icon.y - ICON_SIZE / 2, ICON_SIZE + bang, ICON_SIZE}; + if (is_point_in_rect(mouse, box) || is_point_in_rect(mouse, iconRect)) + hoveredQueue = q; + + int ready = 0; + bool isLocalIn = false; + for (int k = 0; k < count; k++) + { + ready += match.isQueueReady[members[k]]; + isLocalIn |= members[k] == game.local; + } + char line[TOOLTIP_LENGTH]; + if (queue.readyTicks > 0) + SDL_strlcpy(line, "Starting!", sizeof(line)); + else + SDL_snprintf(line, sizeof(line), "%d/%d Ready", ready, queue.rules.players); + float y = area.y + area.h + QUEUE_TEXT_GAP; + Vec2 size = game.text.size_get(line); + game.text.draw(renderer, line, {.color = color, .position = {std::round(area.x + (area.w - size.x) / 2), y}}); + if (!isLocalIn) + continue; + bool isPad = input.devices[DEVICE_KEYBOARD].isAiming; + Binding binding = input.bindings[CONTROL_READY]; + char key[TOOLTIP_LENGTH]; + input.binding_name_get(isPad ? binding.pad : binding.key, isPad, key, sizeof(key)); + SDL_snprintf(line, sizeof(line), "%s: %s", key, match.isQueueReady[game.local] ? "Unready" : "Ready"); + size = game.text.size_get(line); + game.text.draw(renderer, line, + {.color = WHITE, .position = {std::round(area.x + (area.w - size.x) / 2), y + LINE_HEIGHT}}); + } +} + +// The game a hovered queuer has queued, in a tooltip by the mouse. +void Ui::queue_tooltip_draw(Game& game) +{ + if (hoveredQueue < 0 || hoveredQueue >= game.fighterCount || game.menu.isOpen) + return; + const Rules& rules = game.match.queues[hoveredQueue].rules; + Vec2 size = game_summary_size(game, rules); + Vec2 mouse = game.input.mouse; + Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2, + size.y + TOOLTIP_PADDING * 2}; + panel.x = std::round(std::min(panel.x, CANVAS_WIDTH - panel.w)); + panel.y = std::round(std::min(panel.y, CANVAS_HEIGHT - panel.h)); + panel_draw(game.renderer, panel, game.color_get(hoveredQueue, true)); + game_summary_draw(game, rules, {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}); +} diff --git a/src/ui.h b/src/ui.h new file mode 100644 index 0000000..e1ac55f --- /dev/null +++ b/src/ui.h @@ -0,0 +1,303 @@ +#pragma once + +#include "fighter.h" +#include "renderer.h" +#include "settings.h" +#include "text.h" + +struct Game; + +constexpr int TOOLTIP_LENGTH = 512; +constexpr int WEAPON_HANDS = 2; +constexpr float HOTBAR_MARGIN = 8; +constexpr float SLOT_GAP = 4; +constexpr float SLOT_SIZE = 28; +constexpr float WIDGET_GAP = 6; +constexpr float WIDGET_HEIGHT = 14; + +// One item's cooldown bar: how far it has faded in, how much cooldown is left (0 to 1), and the row it sits in among +// the bars showing (eased toward its place as others come and go, -1 while hidden). +struct CooldownBar +{ + float fade = 0; + float fill = 0; + float row = -1; + int item = -1; +}; + +constexpr int STAT_LENGTH = 32; +constexpr int STAT_ROWS = 10; + +// A row of a weapon's stats table: its label, and its value for the attack and for the charged attack (empty where it +// doesn't apply), each marked when the fighter's trinkets change it. +struct StatRow +{ + const char* label = ""; + char values[2][STAT_LENGTH] = {}; + bool isChanged[2] = {}; +}; + +// Text for a weapon or trinket: a white name, gray stats, a weapon's stats table (Attack and Charged columns), then a +// white description and a gray flavor quote. +// The sizes an item icon comes in, and their widths in pixels. +enum IconSize +{ + ICON_FULL, + ICON_SMALL, + ICON_TINY, + ICON_SIZE_COUNT +}; +constexpr float ICON_PIXELS[ICON_SIZE_COUNT] = {24, 16, 8}; + +// The windows for the other fighters along the bottom right in a match: which fighters, where each window sits, the +// icon size that fits them all, and how many icon columns and rows each kind takes (the same on every window). +struct RivalLayout +{ + int count = 0; + int fighters[MAX_FIGHTERS] = {}; + Rect cards[MAX_FIGHTERS] = {}; + IconSize size = ICON_FULL; + int columns[2] = {}; + int rows = 1; + Font font = FONT_SMALL; + float line = 0; +}; + +// What a match's countdown shows, gathered once for wherever it's drawn. +struct CountdownInfo +{ + char round[64] = {}; + int seconds = 0; + int ready = 0; + char key[64] = {}; +}; + +struct Tooltip +{ + const char* name = ""; + char stats[TOOLTIP_LENGTH] = {}; + StatRow rows[STAT_ROWS]; + int rowCount = 0; + char description[TOOLTIP_LENGTH] = {}; + char flavor[TOOLTIP_LENGTH] = {}; + // The item it's about, whose icon it shows top right. + bool isTrinket = false; + int item = -1; +}; + +// How much of each item the round's loadout overview shows, from most to least: everything, everything in the small +// font, the name and level beside the icon, the same in the small font, or just the icons with their levels. +enum LoadoutStyle +{ + LOADOUT_FULL, + LOADOUT_FULL_SMALL, + LOADOUT_BRIEF, + LOADOUT_BRIEF_SMALL, + LOADOUT_ICONS, + LOADOUT_STYLE_COUNT +}; + +// The overview laid out: its style, the box around it all, and each slot's panel (weapons first) and text. +struct LoadoutLayout +{ + Rect bounds; + Rect divider; + Rect panels[2][SLOT_MAX]; + int style = LOADOUT_FULL; + Tooltip tooltips[2][SLOT_MAX]; +}; + +// What a number widget accepts: its range, the step its buttons (and a slider's drag) move in, text to show for zero +// instead of 0, and a suffix after the number. +struct NumberRange +{ + float min = 0; + float max = 1; + float step = 1; + const char* zero = nullptr; + const char* suffix = ""; +}; + +// A scrolling column of widget rows inside an area. Cursor is where the next row goes and height how tall the rows +// came to last frame, which the scroll is kept within. +struct Page +{ + Rect area; + float cursor = 0; + float height = 0; + float scroll = 0; +}; + +constexpr int FOCUS_TARGETS_MAX = 256; + +// A widget the gamepad (or the arrow keys) can move to: who it is (an id, and sub for one of several sharing it), +// where it was drawn, whether left and right adjust it instead of moving away, and whether to land on it first. +struct FocusTarget +{ + Rect rect; + const void* id = nullptr; + int sub = 0; + bool isAdjustable = false; + bool isPreferred = false; +}; + +// One hotbar or menu box. Item is a weapon or trinket number, or -1 for empty, and label 0 means none. +// Lit boxes draw in the tint, the rest gray. A hoverable box under the mouse lights up with an inner border too. +struct Slot +{ + Rect rect; + float alpha = 1; + int item = -1; + int level = 0; + int label = 0; + bool isHoverable = false; + bool isLit = false; + bool isTrinket = false; + // Whether it fades by darkening toward its solid black fill rather than turning see-through, so every pixel stays + // opaque and overlapping parts (icon, chevrons, number) don't show through each other. + bool isOpaque = false; + // Whether it's the menu's target slot, marked by a bar under it. + bool isTarget = false; +}; + +// Everything is tinted with the local fighter's colour (tint, and a darker shade). Fighter bars and names, the match +// banner and score (with a tip each round, chosen when its countdown starts), the weapon and trinket hotbars, tooltips +// and the crosshair, plus drawing helpers the menu shares. Menus also get immediate-mode widgets that draw and +// handle the mouse in one call each frame, returning whether their value changed. Widgets begin sets their fade and +// whether they take input. Active is the value being dragged or typed into, let go once its widget isn't drawn. +// Between focus begin and end (the open menu), widgets can also be focused: the D-pad, left stick or arrow keys move +// the focus to the nearest widget that way (a row at a time up and down), scrolling a page or grid for rows out of +// view, and A or Enter confirms it like a click. Focus shows once a gamepad or arrow key is used, and hides when the +// mouse is. Adjust is left or right pressed on a focused number or slider (-1 or 1). +// How wide a fighter preview's column is (see Ui::fighter_preview_draw). +constexpr float FIGHTER_PREVIEW_WIDTH = 120; + +class Ui +{ +public: + Color tint = WHITE; + Color shade = GRAY; + int adjust = 0; + bool isConfirmed = false; + bool isFocusShown = false; + + void init(Game& game); + void update(Game& game); + void draw(Game& game); + void level_step(Game& game, int& level, int step); + void slot_draw(Game& game, Slot slot); + void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1); + Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, int owner = -1); + void stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip); + void table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, const float* columns, Font font, + float alpha); + Vec2 tooltip_size(Game& game, Tooltip& tooltip, Font font = FONT_SMALL); + void tooltip_text_draw(Game& game, Tooltip& tooltip, Draw draw, Font font = FONT_SMALL); + LoadoutLayout loadout_layout(Game& game, float top, float bottom); + void tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip); + const char* name_get(Game& game, int fighter); + void widgets_begin(float alpha, bool isLive); + void label_draw(Game& game, const char* text, Vec2 position); + void widgets_enable(bool isEnabled); + bool toggle_draw(Game& game, Vec2 position, const char* label, bool& value); + bool radio_draw(Game& game, Vec2 position, const char* label, int& value, int option); + bool button_draw(Game& game, Rect box, const char* label, const void* id = nullptr); + bool slider_draw(Game& game, Rect track, float& value, float step); + bool swatch_draw(Game& game, Rect box, Color color, int& value, int option); + bool number_draw(Game& game, Rect box, float& value, NumberRange range); + bool number_draw(Game& game, Rect box, int& value, NumberRange range); + void separator_draw(Game& game, Rect row, const char* label); + bool fold_draw(Game& game, Rect row, const char* label, bool isOpen); + void fighter_preview_draw(Game& game, Rect area, const char* name, Color tint, const char* caption); + float ping_draw(Game& game, Vec2 corner, float ping, float alpha); + void queues_draw(Game& game); + void queue_tooltip_draw(Game& game); + float ping_width_get(Game& game, float ping); + float game_summary_draw(Game& game, const Rules& rules, Vec2 corner, float alpha = 1); + Vec2 game_summary_size(Game& game, const Rules& rules); + void page_begin(Game& game, Page& page, Rect area); + bool row_get(Page& page, float height, float& y); + void page_end(Game& game, Page& page); + bool field_draw(Game& game, Rect box, char* text, int size); + bool is_active(void* value); + void focus_begin(Game& game); + void focus_end(Game& game); + bool focus_add(const void* id, int sub, Rect rect, bool isAdjustable = false, bool isPreferred = false); + bool is_focused(const void* id, int sub = 0); + int scroll_take(Rect area); + void scroll_mark(); + + // The fighter this screen's camera follows while its own is out of the round (-1 when not spectating). + int spectated = -1; + // The fighter whose queued game the mouse is over, in the lobby's sandbox (-1 for none). + int hoveredQueue = -1; + + RivalLayout rivals_layout(Game& game); + +private: + CooldownBar weaponBars[SLOT_MAX]; + CooldownBar trinketBars[SLOT_MAX]; + char botNames[MAX_FIGHTERS][NAME_LENGTH + 1] = {}; + char playerNames[MAX_FIGHTERS][NAME_LENGTH + 1] = {}; + // Online players' names as shown, with (2), (3)... after names others already have. + char onlineNames[MAX_FIGHTERS][PLAYER_NAME_MAX + 8] = {}; + int pickCursors[MAX_FIGHTERS] = {}; + bool isPickTrinkets[MAX_FIGHTERS] = {}; + void* active = nullptr; + FocusTarget targets[2][FOCUS_TARGETS_MAX]; + Rect focusRect; + const void* focusId = nullptr; + int focusSub = 0; + int navRetry = 0; + int scrollWant = 0; + int targetCounts[2] = {}; + int targetFrame = 0; + bool isFocusing = false; + bool isScrolled = false; + char numberText[TOOLTIP_LENGTH] = {}; + float hintAlpha = 1; + // The switching controls as they were last tick, to catch presses while spectating. + bool spectateHeld[3] = {}; + // How far each HUD hotbar slot (weapons, then trinkets) and each other fighter's window has come up from dim to + // full brightness, from 0 to 1, rising while the mouse is over it. + float slotHovers[2][SLOT_MAX] = {}; + float rivalHovers[MAX_FIGHTERS] = {}; + float loadoutTop = 0; + float widgetAlpha = 1; + int tip = 0; + bool isActiveSeen = false; + bool isMenuSeen = false; + bool isTipChosen = false; + bool isWidgetEnabled = true; + bool isWidgetLive = false; + + void bars_draw(Game& game); + void bar_draw(Renderer& renderer, Rect bar, float fill, Color color); + void match_draw(Game& game); + void loadout_draw(Game& game, float alpha, float top); + void race_draw(Game& game); + float results_draw(Game& game, float y); + void countdown_aside_draw(Game& game, bool isPad); + void text_center_draw(Game& game, const char* text, float y, float scale, float alpha = 1); + void text_center_draw(Game& game, const char* text, float y, float scale, Color color); + void hotbar_draw(Game& game); + void cooldowns_draw(Game& game); + void cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale); + void icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size = ICON_FULL); + void offscreen_draw(Game& game, Vec2 camera); + void status_draw(Game& game); + void panels_draw(Game& game); + void rivals_draw(Game& game); + void spectate_update(Game& game); + void spectate_draw(Game& game); + void picks_draw(Game& game, float top); + void pick_panel_draw(Game& game, int fighter, Rect panel); + void pick_send(Game& game, int fighter, bool isTrinket, int slot, int item); + void crosshairs_draw(Game& game, Vec2 camera); + void tooltip_draw(Game& game, Tooltip& tooltip); + void chamfer_draw(Renderer& renderer, Rect rect, Color color); + Color widget_color(bool isLit); + bool is_hovered(Game& game, Rect box); + bool number_widget(Game& game, Rect box, float& value, NumberRange range, void* id); + void widget_release(Game& game); +}; diff --git a/src/weapon.cpp b/src/weapon.cpp new file mode 100644 index 0000000..18d880c --- /dev/null +++ b/src/weapon.cpp @@ -0,0 +1,2513 @@ +#include "weapon.h" +#include "game.h" +#include + +constexpr int ATTACKS_MAX = 255; +constexpr int DAMAGE_STEP = 5; +constexpr int HEAVY_DAMAGE = 40; +constexpr int HOOK_SUBSTEPS = 4; +constexpr float BOLT_JITTER = 4; +constexpr float BOLT_SEGMENT = 8; +constexpr float BOLT_WIDTH = 2; +constexpr int TESLA_LINKS = 5; +constexpr float TESLA_INTERVAL = 0.2f; +constexpr float TESLA_RADIUS = 4; +constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220}; +constexpr float DEGREE = PI / 180; +constexpr float ARROW_FADE = 0.6f; +constexpr float ARROW_LINGER = 2.5f; +constexpr float ARROW_SPAN = 18; +constexpr float ARROW_THICKNESS = 2; +constexpr float BEAM_INNER = 4; +constexpr float BEAM_OUTER = 10; +constexpr float BEAM_RADIUS = 4; +constexpr float BOOMERANG_LIFETIME = 6; +constexpr float BOOMERANG_RADIUS = 9; +constexpr float BOOMERANG_SPIN = 20; +constexpr float BOOMERANG_STEP = 2; +constexpr float BULLET_RADIUS = 4; +constexpr float CASING_BACK = 0.4f; +constexpr float CAPSULE_STEP = 4; +constexpr float CATCH_DISTANCE = 10; +constexpr float CHAIN_SPACING = 8; +constexpr float EXPLOSION_FLASH = 0.05f; +constexpr float EXPLOSION_GROW = 4; +constexpr float EXPLOSION_OUTLINE = 2; +constexpr float DOUBLER_DELAY = 0.15f; +constexpr float FALLOFF_CURVE = 1.5f; +constexpr float FALLOFF_DIM = 0.15f; +constexpr float FALLOFF_FLOOR = 0.1f; +constexpr float FALLOFF_RANGE = 600; +constexpr float FALLOFF_STEP = 6; +constexpr float FILL_STRENGTH = 2; +constexpr float FILL_JITTER = 2; +// How hard a charging weapon shakes against an attack filled in as far. +constexpr float CHARGE_SHAKE = 0.5f; +constexpr float FILL_PARTICLES = 60; +constexpr float BLAST_RING_ALPHA = 0.2f; +constexpr float COLLISION_ALPHA = 0.25f; +constexpr float PIP_GAP = 3; +constexpr float PIP_SIZE = 2; +constexpr float PIP_SPIN = 8; +constexpr float TRAJECTORY_ALPHA = 0.12f; +constexpr float TRAJECTORY_REACH = 400; +constexpr float TRAJECTORY_STEP = 4; +constexpr float TRAJECTORY_TAPER = 0.25f; +constexpr float FIRE_SHAKE = 0.5f; +constexpr float FIREBALL_RADIUS = 5; +constexpr float FLAME_BRAKE = 0.9f; +constexpr float FLAME_BRAKE_REACH = 16; +constexpr float FLAIL_BOUNCE = 0.3f; +constexpr float FLAIL_BOUNCE_MAX = 0.9f; +constexpr float FLAIL_BOUNCE_PER_RICOCHET = 0.2f; +constexpr float FLAIL_BOUNCE_MIN = 120; +constexpr float FLAIL_FRICTION = 600; +constexpr float FLAIL_RADIUS = 9; +constexpr float FLAIL_SIZE = 12; +constexpr float FLAIL_THUD_SPEED = 250; +constexpr float HIT_INTERVAL = 0.25f; +constexpr float HITSTOP_MAX = 3; +constexpr float HITSTOP_PER_DAMAGE = 0.002f; +constexpr float HITSTOP_MIN_DAMAGE = 10; +constexpr float HIT_PARTICLES_PER_DAMAGE = 0.2f; +constexpr float FLAME_DRAG = 0.9f; +constexpr float FLAME_FADE = 3; +constexpr float FLAME_STAGGER = 1.12f; +constexpr float FLAME_GROW = 0.8f; +constexpr float FLAME_RADIUS = 5; +constexpr float FLAME_RISE = 100; +constexpr float FLAME_SHRINK = 0.6f; +constexpr float HAMMER_HEAD_RADIUS = 8; +constexpr float HAMMER_REACH = 28; +constexpr float HAMMER_REBOUND = 380; +constexpr float HAMMER_REBOUND_CHARGED = 1.5f; +constexpr float HOOK_RADIUS = 6; +constexpr float HOOK_WHIFF_TIME = 0.1f; +constexpr float KICK_DECAY = 0.82f; +constexpr float KICK_SWEEP = 0.1f; +constexpr float RELOAD_DROP = 5; +constexpr float RELOAD_PUMP = 4; +constexpr float RELOAD_START = 0.2f; +constexpr float KNOCK_LIGHT = 0.3f; +constexpr float KNOCK_SLIGHT = 0.1f; +constexpr float KNOCKBACK_REACH = 1.5f; +constexpr float KNOCKBACK_SPEED = 550; +constexpr float LANCE_LENGTH = 40; +constexpr float LANCE_RADIUS = 3; +constexpr float MINE_BLINK = 0.6f; +constexpr float MINE_BLINK_ON = 0.1f; +constexpr float MUZZLE_DISTANCE = 18; +constexpr float POKE_THRUST = 0.35f; +constexpr float PROJECTILE_STEP = 8; +constexpr float RAY_STEP = 2; +constexpr float RICOCHET_BONUS = 0.5f; +constexpr float RECOIL_CURVE = 0.75f; +constexpr float ROCKET_LIFETIME = 3; +constexpr float ROCKET_RADIUS = 4; +constexpr float ROCKET_TAIL = 7; +constexpr float ROPE_INNER = 1; +constexpr float ROPE_OUTER = 3; +constexpr float PUNCH_RADIUS = 6; +constexpr float PUNCH_THRUST = 0.4f; +constexpr float SLASH_DISTANCE = 12; +constexpr float SLASH_GAP = 14; +constexpr float SHAKE_PER_DAMAGE = 0.006f; +constexpr float SLAM_RATIO = 0.75f; +constexpr float SPEED_DAMAGE = 0.05f; +// A normal attack let go partway through a charge hits harder, up to this much more just short of full. +constexpr float PARTIAL_CHARGE_BONUS = 0.5f; +constexpr float SPEED_UNIT = 100; +constexpr float CONTACT_INTERVAL = 0.4f; +constexpr float WISP_FRAME_STAGGER = 0.3f; +constexpr float WISP_GLIDE = 14; +constexpr float WISP_HOME_MIN = 10; +constexpr float WISP_LAUNCH_STAGGER = 0.06f; +constexpr float WISP_LIFETIME = 3; +constexpr float WISP_RADIUS = 4; +constexpr float WISP_RELEASE_SPAN = 0.6f; +constexpr float WISP_RING = 18; +constexpr float WISP_RING_GAP = 10; +constexpr float WISP_SPACING = 12; +constexpr float WISP_SPIN = 2.5f; +constexpr float WISP_TURN = 1.75f; +constexpr float HELD_ICON_SCALE = 0.6f; +constexpr float TELEPORT_PROBE = 4; +constexpr float TELEPORT_STEP = 2; +constexpr float TELEPORT_RING_ALPHA = 0.35f; +constexpr float TELEPORT_CORE = 0.4f; +constexpr float CONTACT_KNOCK = 1; +constexpr float EMBIGGEN_DAMAGE = 1 / 3.0f; +constexpr float SPLASH_EDGE = 0.25f; +constexpr float SPLIT_DAMAGE = 0.6f; +constexpr float SPLIT_SCALE = 0.75f; +constexpr float SPLIT_GAP = 0.06f; +constexpr float SPLIT_GAP_MELEE = 0.5f; +constexpr float SPLIT_GAP_THROWN = 0.3f; +constexpr float SLASH_RADIUS = 14; +constexpr float SLASH_REACH = 26; +constexpr float UPPERCUT_RADIUS = 14; +constexpr float UPPERCUT_REACH = 16; +constexpr float SWING_END = 0.3f * PI; +constexpr float SWING_START = -0.85f * PI; +constexpr float VOLLEY_GAP = 6; +constexpr float WHIP_CONE = 0.25f; +constexpr float WHIP_HANDLE = 4; +constexpr float WHIP_LOOP = 9; +constexpr int WHIP_LOOP_STEPS = 6; +constexpr int WHIP_RAYS = 9; +constexpr float WHIP_UNFURL = 0.6f; +constexpr float GRAPPLE_HANDLE = 7; +constexpr float WHIP_PULL = 220; +constexpr float WHIP_JITTER = 0.5f; +constexpr float WHIP_INNER = 2; +constexpr float WHIP_OUTER = 5; +constexpr float WOBBLE_IMPULSE = 0.15f; +constexpr Vec2 HAMMER_HEAD = {20, 0}; +constexpr float SLUG_RADIUS = 2; +constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1}; +constexpr Burst EMBER_BURST = {.gravity = -150, .life = 0.35f, .size = 2, .speed = 60, .spread = 0.6f, .count = 1}; +constexpr Burst CASING_BURST = {.gravity = 700, .life = 0.6f, .size = 1, .speed = 110, .spread = 0.35f, .count = 1}; +constexpr Burst BLAST_BURST = {.gravity = 200, .life = 0.5f, .size = 4, .speed = 220, .count = 20}; +constexpr Burst HIT_BURST = {.gravity = 300, .speed = 180, .spread = PI / 2, .count = 6}; +constexpr Burst IMPACT_BURST = {.speed = 80, .count = 5}; +constexpr Burst SLAM_BURST = { + .angle = -PI / 2, .gravity = 600, .life = 0.6f, .size = 4, .speed = 260, .spread = PI / 2.5f, .count = 24}; +constexpr Burst SMOKE_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.4f, .count = 1}; +constexpr Burst SPARK_BURST = {.speed = 200, .spread = PI / 2, .count = 12}; +constexpr Burst TRAIL_BURST = {.life = 0.25f, .size = 2, .speed = 120, .spread = 0.2f, .count = 2}; +constexpr Burst WARP_BURST = {.gravity = -60, .life = 0.4f, .size = 2, .speed = 50, .spread = PI, .count = 1}; +constexpr Burst PORTAL_BURST = {.life = 0.4f, .size = 3, .speed = 140, .spread = PI, .count = 14}; + +// Flies a thrown weapon back towards the hand, reporting whether it got there. +static bool is_caught(Fighter& fighter, Attack& attack) +{ + Vec2 toHand = fighter.hand_get() - attack.position; + float distance = vec2_length(toHand); + attack.position = attack.position + vec2_normalize(toHand) * std::min(distance, attack.stats.speed * TICK); + return distance < CATCH_DISTANCE; +} + +// Moves a returning boomerang one tick toward its thrower's hand, sliding along any surface in the way (trying each +// axis alone when the straight step is blocked). Reports whether it's done: caught, or wedged with nowhere to go, +// which breaks it off with a burst. +static bool boomerang_return(Game& game, Fighter& fighter, Attack& attack) +{ + Vec2 toHand = fighter.hand_get() - attack.position; + float distance = vec2_length(toHand); + if (distance < CATCH_DISTANCE) + return true; + // Small steps, so it can't skip through a thin platform either. + float travel = std::min(distance, attack.stats.speed * TICK); + int steps = travel / BOOMERANG_STEP + 1; + Vec2 step = vec2_normalize(toHand) * (travel / steps); + for (int i = 0; i < steps; i++) + { + bool isMoved = false; + for (Vec2 tried : {step, Vec2{step.x, 0}, Vec2{0, step.y}}) + { + Vec2 next = attack.position + tried; + if ((tried.x == 0 && tried.y == 0) || game.world.is_blocking_at(next)) + continue; + attack.position = next; + isMoved = true; + break; + } + if (isMoved) + continue; + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + return true; + } + return false; +} + +// Bounces a direction off the wall between from (open) and to (solid), flipping whichever axis crossed into it, or +// mirroring it off a ramp's surface. +static Vec2 reflect_get(Game& game, Vec2 from, Vec2 to, Vec2 direction) +{ + Vec2 normal = game.world.normal_get(to); + if (normal.x != 0 || normal.y != 0) + return direction - normal * (2 * vec2_dot(direction, normal)); + bool isWallX = game.world.is_blocking_at({to.x, from.y}); + bool isWallY = game.world.is_blocking_at({from.x, to.y}); + if (!isWallX && !isWallY) + { + isWallX = true; + isWallY = true; + } + return {isWallX ? -direction.x : direction.x, isWallY ? -direction.y : direction.y}; +} + +// Plays the sound of a projectile striking a wall or a target. Hooks latch and rockets and fireballs explode +// with their own sounds. +static void impact_play(Game& game, AttackKind kind) +{ + switch (kind) + { + case ATTACK_ARROW: + game.audio.play(SOUND_IMPACT_ARROW); + break; + case ATTACK_BOOMERANG: + game.audio.play(SOUND_IMPACT_BOOMERANG); + break; + case ATTACK_BULLET: + game.audio.play(SOUND_IMPACT_BULLET); + break; + case ATTACK_FLAIL: + game.audio.play(SOUND_FLAIL_LAND); + break; + case ATTACK_MINE: + game.audio.play(SOUND_IMPACT_MINE); + break; + case ATTACK_PROJECTILE: + game.audio.play(SOUND_IMPACT_BLASTER); + break; + default: + break; + } +} + +// Turns a projectile off the wall it's about to enter. Each ricochet doubles its later damage. +static void ricochet(Game& game, Attack& attack, Vec2 next) +{ + attack.velocity = reflect_get(game, attack.position, next, attack.velocity); + attack.angle = vec2_angle(attack.velocity); + attack.bounces--; + attack.ricochets++; + game.audio.play(SOUND_RICOCHET); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); +} + +// Walks the capsule in small steps, writing the first point that touches the rect. +static bool is_capsule_in_rect(Capsule capsule, Rect rect, Vec2& point) +{ + Vec2 delta = capsule.to - capsule.from; + int steps = vec2_length(delta) / CAPSULE_STEP + 1; + + for (int i = 0; i <= steps; i++) + { + Vec2 sample = capsule.from + delta * ((float)i / steps); + Vec2 closest = {std::clamp(sample.x, rect.x, rect.x + rect.w), std::clamp(sample.y, rect.y, rect.y + rect.h)}; + if (vec2_length(sample - closest) > capsule.radius) + continue; + point = closest; + return true; + } + return false; +} + +static bool is_falloff(AttackKind kind) +{ + return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || + kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_HOOK || kind == ATTACK_WISP; +} + +// Ranged shots lose damage linearly with distance flown: full at the muzzle, down to a floor far out. A beam's +// distance runs along it to the point, a hook's is how far it reached from where it was thrown, and a wisp's counts +// only from when it set off (not its orbiting). +static float falloff_at(float distance) +{ + return std::max(FALLOFF_FLOOR, 1 - distance / FALLOFF_RANGE); +} + +static float falloff_get(Attack& attack, Vec2 point) +{ + AttackKind kind = attack.def()->kind; + if (!is_falloff(kind)) + return 1; + float distance = attack.travelled + (kind == ATTACK_BEAM ? vec2_length(point - attack.position) : 0); + if (kind == ATTACK_HOOK) + distance = vec2_length(attack.position - attack.origin); + if (kind == ATTACK_WISP) + distance = attack.length; + return falloff_at(distance); +} + +// How far a rocket, mine or fireball's explosion reaches. It grows with trinkets but not with the attack's own size, so +// a big fireball keeps its listed area. +static float blast_area_get(Attack& attack) +{ + return attack.stats.area * attack.scale / attack.stats.scale; +} + +// What ricochets and falloff do to an attack's damage at a point. +static float multiplier_get(Attack& attack, Vec2 point) +{ + return (1 + attack.ricochets * RICOCHET_BONUS) * falloff_get(attack, point); +} + +// How hard an attack hits right now against its plain damage, with its speed bonus, ricochets and falloff. +static float strength_get(Attack& attack, Vec2 point) +{ + return attack.bonus * multiplier_get(attack, point); +} + +// How much of an attack's black fill its colour takes over, from none at plain damage to all at FILL_STRENGTH. +static float fill_get(float strength) +{ + return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); +} + +// Sheds particles off the edge of an empowered shape, more the more its colour fills in. They take the shape's colour +// and fill in just as far. +static void aura_spawn(Game& game, Capsule shape, float fill, Color color) +{ + Burst burst = AURA_BURST; + burst.fill = fill; + int count = FILL_PARTICLES * fill * TICK + random_get(0, 1); + for (int i = 0; i < count; i++) + { + float angle = random_get(-PI, PI); + Vec2 point = vec2_lerp(shape.from, shape.to, random_get(0, 1)) + vec2_polar(angle, shape.radius); + game.effects.burst(burst, point, angle, color); + } +} + +// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage +// number. +Vec2 Weapons::jitter_get(float fill) +{ + float reach = FILL_JITTER * fill; + return {random_get(-reach, reach), random_get(-reach, reach)}; +} + +// A colour decayed to match a weakened strength: drained toward grey and darkened, faster than the damage drops so +// weakened shots read clearly. +static Color color_dim(Color color, float strength) +{ + strength = math_pow(std::min(1.0f, strength), FALLOFF_CURVE); + float grey = (color.r + color.g + color.b) / 3; + Color washed = {math_lerp(grey, color.r, strength), math_lerp(grey, color.g, strength), + math_lerp(grey, color.b, strength), color.a}; + return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength); +} + +// How much of a hit's knockback an attack delivers: rapid shots and beams barely push, arrows and hooks a little, +// and melee and explosions in full. +static float knock_get(AttackKind kind) +{ + if (kind == ATTACK_BULLET || kind == ATTACK_PROJECTILE || kind == ATTACK_BEAM) + return KNOCK_SLIGHT; + if (kind == ATTACK_ARROW || kind == ATTACK_HOOK) + return KNOCK_LIGHT; + return 1; +} + +// The owner's colour (darker for secondary attacks), dimming as a shot's damage falls off. +static Color owner_color_get(Game& game, Attack& attack) +{ + return color_shade(game.color_get(attack.owner), attack.isSecondary ? SECONDARY_SHADE : 1); +} + +static Color tint_get(Game& game, Attack& attack) +{ + return color_dim(owner_color_get(game, attack), strength_get(attack, attack.tip)); +} + +// A path drawn as an outlined line: an outer width around a black core of the inner width, kinked by jitter. +struct Stroke +{ + std::vector points; + float outer = 0; + float inner = 0; + float jitter = 0; +}; + +// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams +// start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles. +static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) +{ + Color color = owner_color_get(game, attack); + float base = attack.bonus * (1 + attack.ricochets * RICOCHET_BONUS); + Vec2 shake = game.weapons.jitter_get(fill_get(base)); + std::vector points; + std::vector distances; + float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0; + for (size_t i = 0; i + 1 < stroke.points.size(); i++) + { + Vec2 from = stroke.points[i]; + Vec2 delta = stroke.points[i + 1] - from; + Vec2 normal = vec2_normalize({-delta.y, delta.x}); + float length = vec2_length(delta); + int steps = length / FALLOFF_STEP + 1; + for (int step = 0; step < steps; step++) + { + bool isEnd = i == 0 && step == 0; + float nudge = isEnd ? 0 : random_get(-stroke.jitter, stroke.jitter); + points.push_back(from + delta * ((float)step / steps) + normal * nudge + shake); + distances.push_back(distance + length * (step + 0.5f) / steps); + } + distance += length; + } + if (stroke.points.empty()) + return; + points.push_back(stroke.points.back() + shake); + + for (int layer = 0; layer < 2; layer++) + { + for (size_t i = 0; i + 1 < points.size(); i++) + { + float strength = base * falloff_at(distances[i]); + if (layer == 0) + game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); + else + game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength))); + } + } +} + +// A plain lash rolling out: straight from the handle to its front, round a loop that shrinks as it goes, then the rest +// of the whip doubled back above, which starts out behind the fighter. It lies straight once it has cracked. +static std::vector lash_get(Attack& attack) +{ + Vec2 direction = vec2_polar(attack.angle, 1); + Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x}; + float front = vec2_length(attack.tip - attack.position); + float unfurl = attack.length > 0 ? std::min(1.0f, front / attack.length) : 1; + float radius = WHIP_LOOP * (1 - unfurl) * attack.scale; + std::vector points = {attack.position, attack.tip}; + if (radius < 1) + return points; + + Vec2 center = attack.tip + up * radius; + float start = vec2_angle(up * -1); + float turn = vec2_dot(Vec2{up.y, -up.x}, direction) > 0 ? 1 : -1; + for (int i = 1; i <= WHIP_LOOP_STEPS; i++) + points.push_back(center + vec2_polar(start + turn * PI * i / WHIP_LOOP_STEPS, radius)); + points.push_back(attack.tip + up * (radius * 2) - direction * (attack.length - front)); + return points; +} + +// A fighter other than the attacker that's still alive, any dummy, or a block that isn't broken. +// Whether one fighter's attacks can touch another: always in a match, but in the sandbox only between two fighters that +// both have PvP on. +static bool is_hostile(Game& game, int owner, int target) +{ + Match& match = game.match; + return match.state != MATCH_PRACTICE || + (game.fighters[owner].isPvp && game.fighters[target].isPvp && !match.isAway[owner] && !match.isAway[target]); +} + +static bool is_target(Game& game, int target, int owner) +{ + if (target >= MAX_FIGHTERS) + { + World& world = game.world; + int index = target - MAX_FIGHTERS; + return index < (int)world.dummies.size() && + (world.dummies[index].tile < 0 || world.tiles[world.dummies[index].tile] == TILE_BLOCK); + } + return target < game.fighterCount && target != owner && game.fighters[target].isAlive && + is_hostile(game, owner, target); +} + +static Rect target_box(Game& game, int target) +{ + return target < MAX_FIGHTERS ? game.fighters[target].body.box : game.world.dummies[target - MAX_FIGHTERS].box; +} + +// Numbers part way from a level's normal attack to its charged one, damage kept to steps of 5. +static AttackStats stats_blend(const LevelDef& level, float charge) +{ + float damage = math_lerp(level.normal.damage, level.charged.damage, charge); + return { + .damage = std::round(damage / DAMAGE_STEP) * DAMAGE_STEP, + .range = math_lerp(level.normal.range, level.charged.range, charge), + .speed = math_lerp(level.normal.speed, level.charged.speed, charge), + .volley = level.normal.volley, + }; +} + +// How far a Splitter copy turns from the aim. Copies sit a fixed gap apart, so the fan widens as the Splitter adds +// copies: wide gaps for melee, narrower for thrown weapons, tight for shots, all narrowed by spread (Diazepam). Spins +// share the circle evenly. +static float split_offset(AttackKind kind, int split, int splits, float spread) +{ + if (kind == ATTACK_SPIN) + return 2 * PI * split / splits; + + float gap = SPLIT_GAP; + if (kind == ATTACK_SWING || kind == ATTACK_SLASH || kind == ATTACK_POKE || kind == ATTACK_PUNCH || + kind == ATTACK_UPPERCUT) + gap = SPLIT_GAP_MELEE; + if (kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_MINE) + gap = SPLIT_GAP_THROWN; + return gap * (split - (splits - 1) / 2.0f) * spread; +} + +// Walks from start along angle until range runs out or isBlocked passes, returning where it stopped. +static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) +{ + Vec2 point = start; + while (vec2_length(point - start) < range && !isBlocked(point)) + point = point + vec2_polar(angle, RAY_STEP); + return point; +} + +// The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the +// hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip. +static int shapes_get(Game& game, Attack& attack, Capsule* shapes) +{ + if (attack.def()->kind != ATTACK_WHIP || attack.isCharged) + { + shapes[0] = {attack.position, attack.tip, attack.radius}; + return 1; + } + auto isBlocked = [&](Vec2 point) { + return game.world.is_blocking_at(point); + }; + float front = vec2_length(attack.tip - attack.origin); + for (int ray = 0; ray < WHIP_RAYS; ray++) + { + float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1); + shapes[ray] = {attack.origin, ray_march(attack.origin, angle, front, isBlocked), attack.radius}; + } + return WHIP_RAYS; +} + +// Fires the weapon in hand with the given charge if it's ready. Full charge uses the charged attack. +// With the Doubler on, the same attack repeats a moment later for a fraction of the damage (stacked Doublers each +// add a repeat of their own, one after another). With Dual Wield on, the off-hand weapon fires too, for a fraction of +// its damage. Every weapon (see Fighter::weapon_key) keeps its own cooldown, in either hand, so swapping to another +// weapon never inherits the last one's wait. +void Weapons::fire(Game& game, Fighter& fighter, float charge) +{ + int held = fighter.weapon_key(fighter.slot); + float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); + if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held)) + { + bool isSpawned = is_spawned(game, fighter, {.charge = charge, .weaponSlot = held}); + fighter.cooldowns[held] = fighter.level_get(held).cooldown / gloves; + // Each Doubler copy adds an echo, at its own level's power. + for (int i = 0, repeat = 0; isSpawned && i < fighter.trinketSlots; i++) + { + if (fighter.trinketLoadout[i] != TRINKET_DOUBLER) + continue; + float power = TRINKET_DEFS[TRINKET_DOUBLER].values[fighter.trinketSlotLevels[i]]; + fighter.echoes.push_back({charge, DOUBLER_DELAY * ++repeat, power, held}); + } + } + + int off = fighter.off_slot_get(); + off = off < 0 ? -1 : fighter.weapon_key(off); + float dual = fighter.trinket_get(TRINKET_DUAL, 0); + if (dual > 0 && off >= 0 && off != held && fighter.cooldowns[off] <= 0 && !is_busy(fighter, off)) + { + is_spawned(game, fighter, + {.charge = charge, .power = dual, .weaponSlot = off, .isOffhand = true, .isSecondary = true}); + fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves; + } +} + +// Whether an attack flies on its own, which the Accelerator speeds up. +static bool is_projectile(AttackKind kind, bool isCharged) +{ + return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || + kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_FLAME || kind == ATTACK_FIREBALL || + kind == ATTACK_WISP || (kind == ATTACK_HOOK && !isCharged); +} + +// Whether Ricochet lets an attack ricochet off walls. +static bool is_bouncy(AttackKind kind) +{ + return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || + kind == ATTACK_BEAM || kind == ATTACK_WISP; +} + +// Shots flying on their own velocity gain the part of the fighter's velocity heading along the aim, so they still fly +// where aimed and running away never slows them. Boomerangs fly their own path, and wisps set off from a standstill. +static bool is_inheriting(AttackKind kind, bool isCharged) +{ + return is_projectile(kind, isCharged) && kind != ATTACK_BOOMERANG && kind != ATTACK_WISP; +} + +// Whether the fighter's box would fit with its middle at centre, clear of walls (and inside the map). +static bool is_box_clear(Game& game, Fighter& fighter, Vec2 centre) +{ + Rect box = fighter.body.box; + int columns = (int)(box.w / TELEPORT_PROBE) + 1; + int rows = (int)(box.h / TELEPORT_PROBE) + 1; + for (int row = 0; row <= rows; row++) + for (int column = 0; column <= columns; column++) + { + Vec2 point = {centre.x - box.w / 2 + box.w * column / columns, centre.y - box.h / 2 + box.h * row / rows}; + if (game.world.is_blocking_at(point)) + return false; + } + return true; +} + +// Where a teleport toward the cursor lands: the cursor, held within range of the fighter's middle, backed off toward +// the fighter until its box fits there. Returns the fighter's own middle if nowhere fits. +static Vec2 teleport_spot(Game& game, Fighter& fighter, Vec2 target, float range) +{ + Vec2 centre = fighter.center_get(); + Vec2 offset = target - centre; + float distance = std::min(range, vec2_length(offset)); + Vec2 direction = vec2_normalize(offset); + for (float along = distance; along > 0; along -= TELEPORT_STEP) + { + Vec2 spot = centre + direction * along; + if (is_box_clear(game, fighter, spot)) + return spot; + } + return centre; +} + +// The fighter's speed along the aim that an inheriting shot gains. Launched fighters lend nothing. +static float push_get(Fighter& fighter, Vec2 aim) +{ + return fighter.isLaunched ? 0 : std::max(0.0f, vec2_dot(fighter.body.velocity, aim)); +} + +// The damage multiplier a fighter's speed gives an attack fired now along aim, with the Speedometer adding to it. +// Inheriting shots count only their push, everything else the fighter's whole speed. +static float bonus_get(Fighter& fighter, AttackKind kind, bool isCharged, Vec2 aim) +{ + float speed = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : vec2_length(fighter.body.velocity); + return 1 + (SPEED_DAMAGE + fighter.trinket_get(TRINKET_SPEEDOMETER, 0)) * speed / SPEED_UNIT; +} + +// How much a fighter's colour fills in from the bonus its held weapon would get firing right now. +float Weapons::buff_get(Fighter& fighter) +{ + int held = fighter.weapon_get(); + if (held < 0) + return 0; + bool isCharged = fighter.charge >= 1; + AttackKind kind = isCharged ? WEAPON_DEFS[held].charged.kind : WEAPON_DEFS[held].normal.kind; + return fill_get(bonus_get(fighter, kind, isCharged, fighter.aim)); +} + +// What an attack does for this fighter, mirroring is_spawned and damage_deal: Splitter copies hit for less and come +// out smaller, Embiggener adds damage, Gloves and Energy Drink speed up attacking and charging, Enlarger grows blasts +// and melee reach, the Accelerator speeds up projectiles, Diazepam tightens spread and Shock Absorber eases lunges. +// "Every" is the cooldown, or for a charged attack the time to charge. +AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool isCharged) +{ + const WeaponDef& def = WEAPON_DEFS[weapon]; + const AttackDef& attack = isCharged ? def.charged : def.normal; + const LevelDef& levelDef = def.levels[level]; + AttackStats stats = isCharged ? levelDef.charged : levelDef.normal; + AttackKind kind = attack.kind; + bool isTeleport = kind == ATTACK_TELEPORT; + int splits = isTeleport ? 1 : (int)fighter.trinket_get(TRINKET_SPLITTER, 1); + float splitScale = splits > 1 ? SPLIT_SCALE : 1; + float embiggen = 1 + (fighter.trinket_get(TRINKET_EMBIGGENER, 1) - 1) * EMBIGGEN_DAMAGE; + float enlarger = fighter.trinket_get(TRINKET_ENLARGER, 1); + float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); + float drink = fighter.trinket_get(TRINKET_DRINK, 1); + float accelerator = is_projectile(kind, isCharged) ? fighter.trinket_get(TRINKET_ACCELERATOR, 1) : 1; + float steadiness = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0); + float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1); + bool isMelee = kind == ATTACK_SWING || kind == ATTACK_SLASH; + + AttackSummary summary; + summary.isDamaging = kind != ATTACK_DETONATE && stats.damage > 0; + float damage = splits > 1 ? std::max(1.0f, std::round(stats.damage * SPLIT_DAMAGE)) : stats.damage; + summary.damage = std::round(damage * embiggen); + summary.isDamageChanged = summary.damage != stats.damage; + summary.every = isCharged ? CHARGE_TIME / drink : levelDef.cooldown / gloves; + summary.isEveryChanged = isCharged ? drink != 1 : gloves != 1; + summary.range = stats.range * (isMelee ? enlarger * splitScale : 1); + summary.isRangeChanged = summary.range != stats.range; + summary.area = stats.area * attack.scale * enlarger * splitScale; + summary.isAreaChanged = enlarger != 1 || splits > 1; + summary.speed = is_projectile(kind, isCharged) ? stats.speed * accelerator : 0; + summary.isSpeedChanged = accelerator != 1; + summary.spread = attack.spread * steadiness; + summary.isSpreadChanged = steadiness != 1; + summary.lunge = stats.lunge * absorb; + summary.isLungeChanged = absorb != 1; + summary.pieces = stats.volley * splits; + summary.isPiecesChanged = splits > 1; + // A detonator only sets off what's already out: one press, nothing of its own. + if (kind == ATTACK_DETONATE) + { + summary.range = summary.area = summary.speed = 0; + summary.pieces = 1; + summary.isPiecesChanged = false; + } + if (levelDef.limit > 0 && !isCharged) + summary.out = levelDef.limit; + else if (kind == ATTACK_BOOMERANG || kind == ATTACK_MINE) + summary.out = levelDef.count; + return summary; +} + +// Builds and launches a weapon's attack, reporting whether anything fired. Every attack leaves a little off the aim, by +// as much as its spread, which Diazepam steadies. Every fighter hits harder the faster it's moving (or, for +// shots, the faster it moves along the aim), and the Speedometer adds to that. Trinkets speed up projectiles and grow +// attacks here. Detonating sets off the weapon's mines instead, and mines won't place past the level's count. A level +// with a limit fires only as many pieces as fit under it (see LevelDef). +bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) +{ + int key = shot.weaponSlot; + int held = fighter.weaponLoadout[key]; + float charge = shot.charge; + float power = shot.power; + const WeaponDef& weapon = WEAPON_DEFS[held]; + const LevelDef& level = fighter.level_get(key); + bool isCharged = charge >= 1; + const AttackDef& def = isCharged ? weapon.charged : weapon.normal; + AttackStats stats = isCharged ? level.charged : level.normal; + if (weapon.isDrawn) + stats = stats_blend(level, charge); + + AttackKind kind = def.kind; + int mines = 0; + for (Attack& attack : attacks) + mines += attack.def()->kind == ATTACK_MINE && attack.owner == fighter.index && attack.key == key && + !attack.isDead && !attack.isCopy; + if (kind == ATTACK_MINE && !shot.isEcho && mines >= level.count) + return false; + if (level.limit > 0 && !shot.isSecondary) + { + int out = 0; + for (Attack& attack : attacks) + out += attack.def() == &def && attack.owner == fighter.index && attack.key == key && !attack.isCopy && + !attack.isSecondary && !attack.isDead; + if (out >= level.limit) + return false; + stats.volley = std::min(stats.volley, level.limit - out); + } + + if (!shot.isSecondary) + { + fighter.kickFrom = weapon.kick; + fighter.kickTo = weapon.follow; + fighter.kickTime = 0; + } + if (kind == ATTACK_DETONATE) + { + // With nothing armed to set off, the detonator just clicks. + bool isLive = false; + for (Attack& attack : attacks) + { + if (!is_mine_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead) + continue; + attack_explode(game, attack); + isLive = true; + } + game.audio.play(isLive ? def.sound : SOUND_DETONATE_EMPTY); + return true; + } + + if (is_projectile(kind, isCharged)) + stats.speed *= fighter.trinket_get(TRINKET_ACCELERATOR, 1); + float spread = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0); + float jitter = def.spread * DEGREE * spread; + Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + game.rng.random_get(-jitter, jitter), 1); + float push = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : 0; + // A part-charged normal attack (not a drawn one, whose draw already strengthens it) adds to the speed bonus, so it + // hits harder and fills in, glows and shakes the same way. + float bonus = bonus_get(fighter, kind, isCharged, aim); + if (!isCharged && !weapon.isDrawn) + bonus *= 1 + charge * PARTIAL_CHARGE_BONUS; + stats.damage = std::max(1.0f, std::round(stats.damage * power * bonus)); + + Attack attack = { + .stats = stats, + .origin = fighter.hand_get(), + .velocity = aim * stats.speed, + .aimAngle = vec2_angle(aim), + .angle = vec2_angle(aim), + .bonus = bonus, + .duration = stats.duration > 0 ? stats.duration : def.duration, + .scale = def.scale * stats.scale, + .bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0, + .hitsLeft = def.hits, + .owner = fighter.index, + .weapon = held, + .key = key, + .isCharged = isCharged, + .isLeft = fighter.is_left(), + .isOffhand = shot.isOffhand, + .isSecondary = shot.isSecondary, + }; + attack.position = attack.origin + aim * MUZZLE_DISTANCE; + + // A teleport is one spell however many copies anything else would make: it marks where it will land. + if (kind == ATTACK_TELEPORT) + { + attack.position = teleport_spot(game, fighter, fighter.controls.cursor, stats.range); + attack.origin = fighter.center_get(); + attack.tip = attack.position; + attack.hitsLeft = 0; + attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1); + attack.isCopy = shot.isEcho; + attack_remember(attack); + attacks.push_back(attack); + game.audio.play(shot.isEcho ? SOUND_ECHO : def.sound); + return true; + } + + if (kind == ATTACK_UPPERCUT) + attack.angle -= (fighter.is_left() ? -1 : 1) * PI / 2; + if (kind == ATTACK_SWING) + attack.scale = stats.range / HAMMER_REACH; + if (kind == ATTACK_SLASH) + attack.scale = stats.range / SLASH_REACH; + attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1); + if (kind == ATTACK_POKE) + attack.duration = std::max(TICK, stats.range - LANCE_LENGTH * attack.scale) / stats.speed / POKE_THRUST; + + int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); + if (splits > 1) + { + attack.scale *= SPLIT_SCALE; + attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * SPLIT_DAMAGE)); + } + + // One piece per volley shot per split, each fanned around the aim (or set side by side when the volley has no + // fan). The centre split stays primary and the rest are secondary. + for (int v = 0; v < stats.volley; v++) + { + float place = stats.volley > 1 ? (float)v / (stats.volley - 1) - 0.5f : 0; + for (int split = 0; split < splits; split++) + { + float offset = 0; + if (stats.volley > 1) + offset += (stats.fan * place + game.rng.random_get(-def.scatter, def.scatter) * DEGREE) * spread; + if (splits > 1) + offset += split_offset(kind, split, splits, spread); + + Attack piece = attack; + if (stats.fan <= 0) + piece.origin = + piece.origin + vec2_polar(attack.angle + PI / 2, VOLLEY_GAP * (stats.volley - 1) * place); + piece.angle += offset; + piece.aimAngle += offset; + piece.velocity = vec2_polar(piece.angle, stats.speed + push); + piece.position = piece.origin + vec2_polar(piece.angle, MUZZLE_DISTANCE); + piece.isSecondary |= split != splits / 2; + piece.isCopy = shot.isEcho || split != splits / 2; + if (kind == ATTACK_WISP) + { + // Wisps rise out of the fighter's middle to join its orbit (see orbits_update). Cast with attack held + // they gather there until it's let go (see wisps_release); otherwise each sets off a little after the + // last. + int index = v * splits + split; + piece.isGathering = fighter.is_down(CONTROL_ATTACK); + piece.slot = index; + piece.duration = attack.duration + index * WISP_LAUNCH_STAGGER; + piece.position = fighter.center_get(); + piece.orbit = {}; + piece.velocity = {}; + // Until it sets off, its angle is only how far its spread (and any split) turns it off the aim. + piece.angle = std::remainder(piece.aimAngle - vec2_angle(fighter.aim), 2 * PI); + } + piece_launch(game, piece); + attacks.push_back(piece); + } + } + game.audio.play(shot.isEcho ? SOUND_ECHO : def.sound); + + Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE; + float side = fighter.is_left() ? -1 : 1; + if (weapon.flash > 0) + game.effects.flash_spawn(muzzle, vec2_angle(aim), weapon.flash, game.color_get(fighter.index)); + if (weapon.isEjecting) + game.effects.burst(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), + game.color_get(fighter.index)); + if (isCharged) + game.effects.shake_add(def.shake * FIRE_SHAKE); + + // An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber + // softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil. + float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1); + Vec2 lunge = kind == ATTACK_UPPERCUT ? vec2_polar(attack.angle, 1) : aim; + if (kind == ATTACK_UPPERCUT) + fighter.body.velocity = fighter.body.velocity - lunge * std::min(0.0f, vec2_dot(fighter.body.velocity, lunge)); + if (stats.lunge > 0) + fighter.burst_add(lunge * stats.lunge * absorb); + if (stats.lunge < 0) + fighter.impulse_add(aim * stats.lunge * absorb); + float recoil = + fighter.trinket_get(TRINKET_RECOIL, 0) * math_pow(stats.damage * stats.volley, RECOIL_CURVE) * absorb; + if (recoil > 0) + fighter.impulse_add(fighter.aim * -recoil); + return true; +} + +void Weapons::update(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + contact_update(game, game.fighters[i]); + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + if (fighter.hitstop > 0) + continue; + Rect box = fighter.body.box; + float radius = box.w / 2; + if (fighter.isAlive) + aura_spawn(game, {{box.x + radius, box.y + radius}, {box.x + radius, box.y + box.h - radius}, radius}, + buff_get(fighter), game.color_get(i)); + for (Echo& echo : fighter.echoes) + { + echo.timer -= TICK; + if (echo.timer <= 0 && echo.weaponSlot == fighter.weapon_key(fighter.slot)) + is_spawned(game, fighter, + { + .charge = echo.charge, + .power = echo.power, + .weaponSlot = echo.weaponSlot, + .isEcho = true, + .isSecondary = true, + }); + } + fighter.echoes.erase_if([](Echo& echo) { return echo.timer <= 0; }); + } + + orbits_update(game); + + // A fighter's attacks freeze with it in hitstop. + for (Attack& attack : attacks) + { + if (game.fighters[attack.owner].hitstop > 0) + { + attack_remember(attack); + continue; + } + bool isNew = attack.time == 0; + attack_remember(attack); + attack_move(game, attack); + if (is_falloff(attack.def()->kind)) + attack.travelled += vec2_length(attack.position - attack.positionPrevious); + if (isNew) + attack_remember(attack); + attack.hitCooldown -= TICK; + AttackKind kind = attack.def()->kind; + bool isResting = (kind == ATTACK_MINE && attack.time >= attack.duration) || kind == ATTACK_FLAME; + bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP; + if (!isLine && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) + game.effects.afterimage_spawn(attack_pose(game, attack)); + if (!attack.isAttached) + aura_spawn(game, {attack.position, attack.tip, attack.radius}, fill_get(strength_get(attack, attack.tip)), + tint_get(game, attack)); + + // Dummies and blocks are checked before fighters, so racers crowding a block can't soak up every swing at it. + Capsule shapes[WHIP_RAYS]; + int shapeCount = shapes_get(game, attack, shapes); + int targets = MAX_FIGHTERS + game.world.dummies.size(); + for (int i = 0; i < targets; i++) + { + int target = (i + MAX_FIGHTERS) % targets; + if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !is_target(game, target, attack.owner)) + continue; + for (int shape = 0; shape < shapeCount; shape++) + { + Vec2 point; + if (!is_capsule_in_rect(shapes[shape], target_box(game, target), point)) + continue; + attack_hit(game, attack, target, point); + break; + } + } + attack.time += TICK; + } + tesla_update(game); + + // Standing in your own lingering flames brakes you, launches included. + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + for (Attack& attack : attacks) + { + bool isNear = vec2_length(attack.position - fighter.center_get()) < FLAME_BRAKE_REACH; + if (attack.owner != i || attack.def()->kind != ATTACK_FLAME || attack.isDead || !isNear) + continue; + fighter.body.velocity = fighter.body.velocity * FLAME_BRAKE; + break; + } + } + attacks_cap(game); + std::erase_if(attacks, [](Attack& attack) { return attack.isDead; }); + attacks.insert(attacks.end(), pending.begin(), pending.end()); + pending.clear(); +} + +// No fighter keeps more than ATTACKS_MAX attacks out: past that, their oldest loose ones (anything not in or tethered +// to a hand and not an explosion going off, so mines, stuck arrows, wisps and flames) fizzle out with a puff, oldest +// first. Attacks are kept in the order they were made, so the first found are the oldest. +void Weapons::attacks_cap(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + { + int count = 0; + for (Attack& attack : attacks) + count += attack.owner == i && !attack.isDead; + for (Attack& attack : attacks) + { + if (count <= ATTACKS_MAX) + break; + AttackKind kind = attack.def()->kind; + bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_SPIN || kind == ATTACK_FLAIL || + kind == ATTACK_WHIP || kind == ATTACK_BEAM || kind == ATTACK_HOOK || kind == ATTACK_EXPLOSION; + if (attack.owner != i || attack.isDead || isHeld) + continue; + attack.isDead = true; + count--; + game.effects.burst(SMOKE_BURST, attack.position, 0, tint_get(game, attack)); + } + } +} + +// Advances an attack by one tick according to its kind and updates its hit capsule. +void Weapons::attack_move(Game& game, Attack& attack) +{ + const AttackDef& def = *attack.def(); + Fighter& fighter = game.fighters[attack.owner]; + float progress = attack.time / attack.duration; + float range = attack.stats.range; + + switch (def.kind) + { + case ATTACK_SWING: + { + // The overhead arc is laid out around the aim, mirrored when facing left. + float swing = SWING_START + (SWING_END - SWING_START) * std::min(1.0f, progress * progress); + float side = attack.isLeft ? -1 : 1; + attack.angle = attack.aimAngle + swing * side; + attack.origin = fighter.hand_get(); + attack.position = attack.origin + vec2_polar(attack.angle, HAMMER_HEAD.x * attack.scale) + + vec2_polar(attack.angle + PI / 2, HAMMER_HEAD.y * attack.scale * side); + attack.radius = HAMMER_HEAD_RADIUS * attack.scale; + + // A head striking a wall stops the swinger's motion along the aim and boosts them back against it (aiming + // down at the floor pops them up), once per swing. + if (!attack.isRebounded && game.world.is_blocking_at(attack.position)) + { + Vec2 away = vec2_polar(attack.aimAngle + PI, 1); + float into = vec2_dot(fighter.body.velocity, away); + fighter.body.velocity = fighter.body.velocity - away * std::min(0.0f, into); + fighter.impulse_add(away * HAMMER_REBOUND * (attack.isCharged ? HAMMER_REBOUND_CHARGED : 1) * + fighter.trinket_get(TRINKET_ABSORBER, 1)); + attack.isRebounded = true; + game.audio.play(SOUND_BOUNCE); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + } + if (progress < 1) + break; + + attack.isDead = true; + if (!attack.isCharged) + break; + attack.radius = range * SLAM_RATIO; + game.audio.play(SOUND_SLAM); + game.effects.shake_add(def.shake); + game.effects.burst(SLAM_BURST, attack.position, 0, game.color_get(attack.owner)); + break; + } + case ATTACK_SLASH: + attack.position = attack.origin + vec2_polar(attack.angle, SLASH_DISTANCE * attack.scale + SLASH_GAP) + + attack.velocity * attack.time; + attack.radius = SLASH_RADIUS * attack.scale; + attack.isDead = progress >= 1; + break; + case ATTACK_PUNCH: + attack.origin = fighter.hand_get(); + attack.position = attack.origin + vec2_polar(attack.angle, range * std::min(1.0f, progress / PUNCH_THRUST)); + attack.radius = PUNCH_RADIUS * attack.scale; + attack.isDead = progress >= 1; + break; + case ATTACK_UPPERCUT: + { + // The fist rises beside its fighter on the aimed side, carried up with them. Splitter's smaller copies + // keep the full reach so they fan out around the fighter instead of piling up on it. + float reach = UPPERCUT_REACH * attack.scale; + if (fighter.trinket_get(TRINKET_SPLITTER, 1) > 1) + reach /= SPLIT_SCALE; + attack.origin = fighter.center_get(); + attack.position = attack.origin + vec2_polar(attack.aimAngle, reach); + attack.radius = UPPERCUT_RADIUS * attack.scale; + attack.isDead = progress >= 1; + break; + } + case ATTACK_POKE: + { + float extend = progress < POKE_THRUST ? progress / POKE_THRUST : (1 - progress) / (1 - POKE_THRUST); + attack.origin = fighter.hand_get(); + attack.position = attack.origin + + vec2_polar(attack.angle, std::max(0.0f, extend) * (range - LANCE_LENGTH * attack.scale)); + attack.tip = attack.position + vec2_polar(attack.angle, LANCE_LENGTH * attack.scale); + attack.radius = LANCE_RADIUS * attack.scale; + attack.isDead = progress >= 1; + if (attack.isCharged && progress < POKE_THRUST) + game.effects.burst(TRAIL_BURST, attack.tip, attack.angle + PI, game.color_get(attack.owner)); + break; + } + case ATTACK_ARROW: + if (attack.isAttached) + { + attack.isDead = attack.time >= attack.duration; + break; + } + attack.velocity.y += ARROW_GRAVITY * TICK; + [[fallthrough]]; + case ATTACK_BULLET: + attack.angle = vec2_angle(attack.velocity); + [[fallthrough]]; + case ATTACK_PROJECTILE: + { + // Small steps so fast shots can't skip through thin walls. The hit capsule spans the whole tick's + // path so they can't skip past targets either. + Vec2 start = attack.position; + int steps = vec2_length(attack.velocity) * TICK / PROJECTILE_STEP + 1; + attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS : BULLET_RADIUS) * attack.scale; + for (int step = 0; step < steps && !attack.isDead; step++) + { + Vec2 next = attack.position + attack.velocity * (TICK / steps); + bool isWall = game.world.is_blocking_at(next); + if (isWall && attack.bounces > 0) + { + ricochet(game, attack, next); + continue; + } + + // Arrows stick where they land and linger, harmless, as footholds for their archer. Blocks take the hit + // instead. + if (isWall && def.kind == ATTACK_ARROW && game.world.tile_at(next) != TILE_BLOCK) + { + attack.isAttached = true; + attack.hitsLeft = 0; + attack.velocity = {}; + attack.duration = attack.time + ARROW_LINGER; + impact_play(game, def.kind); + game.effects.burst(IMPACT_BURST, next, 0, game.color_get(attack.owner)); + break; + } + + attack.position = next; + bool isBlocked = game.world.is_blocking_at(attack.position); + if (!isBlocked && vec2_length(attack.position - attack.origin) < range) + continue; + attack.isDead = true; + if (isBlocked) + impact_play(game, def.kind); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + } + attack.tip = start; + break; + } + case ATTACK_ROCKET: + { + float speed = std::min(attack.stats.speed * ROCKET_SPEEDUP, + vec2_length(attack.velocity) + ROCKET_ACCELERATION * TICK); + attack.velocity = vec2_polar(attack.angle, speed); + attack.radius = ROCKET_RADIUS * attack.scale; + Vec2 next = attack.position + attack.velocity * TICK; + if (game.world.is_blocking_at(next) && attack.bounces > 0) + { + ricochet(game, attack, next); + break; + } + attack.position = next; + game.effects.burst(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, + game.color_get(attack.owner)); + + bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range; + if (game.world.is_blocking_at(attack.position) || isSpent) + attack_explode(game, attack); + break; + } + case ATTACK_WISP: + wisp_move(game, attack); + break; + case ATTACK_TELEPORT: + teleport_move(game, attack); + break; + case ATTACK_DETONATE: + break; + case ATTACK_EXPLOSION: + attack.radius = range; + attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; + attack.isDead = progress >= 1; + break; + case ATTACK_BEAM: + // A beam hits only as it's fired, then fades where it was: its first stretch stays strung from the hand + // to where it struck, and each stretch it ricocheted along stays where it bounced. + attack.radius = BEAM_RADIUS * attack.scale; + attack.isDead = progress >= 1; + attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; + if (attack.ricochets == 0) + { + attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + attack.angle = vec2_angle(attack.tip - attack.origin); + attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); + } + break; + case ATTACK_WHIP: + { + // A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at + // once. A plain lash rolls out from the handle, going with the hand, sweeping down across its cone from + // the upper edge, and hits anywhere in the cone out to its front until it cracks at full reach (short of + // any wall). + attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + attack.radius = BEAM_RADIUS * attack.scale; + attack.isDead = progress >= 1; + if (attack.isCharged) + { + attack.angle = vec2_angle(attack.tip - attack.origin); + attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); + attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; + break; + } + auto isBlocked = [&](Vec2 point) { + return game.world.is_blocking_at(point); + }; + float unfurl = std::min(1.0f, progress / WHIP_UNFURL); + attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * (2 * unfurl - 1); + Vec2 reach = ray_march(attack.origin, attack.angle, range, isBlocked); + attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); + attack.length = std::max(0.0f, vec2_length(reach - attack.position)); + attack.tip = attack.position + vec2_polar(attack.angle, attack.length * unfurl * (2 - unfurl)); + attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0; + break; + } + case ATTACK_BOOMERANG: + { + // Flies out easing to its peak, hovers there for its hold, then comes back to the hand. Touching a surface + // on the way out ends the throw there: a plain one turns straight back, a charged one hovers where it hit. + // Coming back, it slides along surfaces, and breaks off if it's wedged with nowhere to go. + attack.isReturning |= attack.time >= attack.duration + def.hold; + attack.radius = BOOMERANG_RADIUS * attack.scale; + if (attack.isReturning) + { + attack.isDead = boomerang_return(game, fighter, attack) || attack.time > BOOMERANG_LIFETIME; + break; + } + if (attack.time >= attack.duration) + break; + // Walked in small steps so it can't skip through a thin platform. + float eased = 1 - (1 - progress) * (1 - progress); + Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased); + Vec2 start = attack.position; + int steps = vec2_length(next - start) / BOOMERANG_STEP + 1; + bool isBlocked = false; + for (int step = 1; step <= steps && !isBlocked; step++) + { + Vec2 point = vec2_lerp(start, next, (float)step / steps); + isBlocked = game.world.is_blocking_at(point); + if (!isBlocked) + attack.position = point; + } + if (!isBlocked) + break; + impact_play(game, def.kind); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + attack.duration = attack.time; + attack.isReturning = !attack.isCharged; + break; + } + case ATTACK_SPIN: + { + // Keeps circling while left is held, finishing the current lap once let go. + int hand = attack.isOffhand ? fighter.off_slot_get() : fighter.slot; + bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; + if (isHeld && attack.time >= attack.duration) + { + attack.time -= attack.duration; + game.audio.play(def.sound); + } + attack.angle += (attack.isLeft ? -2 : 2) * PI * TICK / attack.duration; + attack.position = fighter.center_get() + vec2_polar(attack.angle, range); + attack.radius = FLAIL_RADIUS * attack.scale; + attack.isDead = !isHeld && progress >= 1; + break; + } + case ATTACK_FLAME: + { + // Slows quickly out of the nozzle and drifts upward, stopping against walls. + attack.velocity = attack.velocity * FLAME_DRAG + Vec2{0, -FLAME_RISE * TICK}; + Vec2 next = attack.position + attack.velocity * TICK; + if (game.world.is_blocking_at(next)) + attack.velocity = {}; + else + attack.position = next; + attack.radius = FLAME_RADIUS * attack.scale; + attack.isDead = progress >= 1; + break; + } + case ATTACK_FIREBALL: + attack.radius = FIREBALL_RADIUS * attack.scale; + attack.position = attack.position + attack.velocity * TICK; + game.effects.burst(EMBER_BURST, attack.position, attack.angle + PI, game.color_get(attack.owner)); + if (game.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range) + attack_explode(game, attack); + break; + case ATTACK_MINE: + { + // Glides out along the aim, easing to a stop, and floats there, arming (and blinking) a moment later. Walls + // stop it early. + if (progress >= 1) + break; + float eased = 1 - (1 - progress) * (1 - progress); + Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased); + if (game.world.is_blocking_at(next)) + { + attack.duration = attack.time; + impact_play(game, def.kind); + } + else + attack.position = next; + break; + } + case ATTACK_FLAIL: + attack.radius = FLAIL_RADIUS * attack.scale; + if (attack.isReturning) + attack.isDead = is_caught(fighter, attack); + else + flail_move(game, attack); + break; + case ATTACK_HOOK: + { + attack.radius = HOOK_RADIUS * attack.scale; + if (attack.isAttached) + break; + if (attack.isCharged) + { + attack.isDead = attack.time >= HOOK_WHIFF_TIME; + break; + } + + for (int step = 0; step < HOOK_SUBSTEPS && !attack.isAttached; step++) + { + attack.position = attack.position + attack.velocity * (TICK / HOOK_SUBSTEPS); + if (game.world.is_blocking_at(attack.position)) + hook_attach(game, attack); + } + attack.isDead = !attack.isAttached && vec2_length(attack.position - fighter.hand_get()) > range; + break; + } + } + + bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW; + if (def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP && !isShot) + attack.tip = attack.position; +} + +// Keeps this tick's pose so drawing can blend from it. New attacks remember their first pose +// so they don't sweep in from where they were spawned. +void Weapons::attack_remember(Attack& attack) +{ + attack.originPrevious = attack.origin; + attack.positionPrevious = attack.position; + attack.tipPrevious = attack.tip; + attack.anglePrevious = attack.angle; + attack.timePrevious = attack.time; +} + +// Moves every attack alpha of the way through the last tick. Only for drawing. +void Weapons::blend(float alpha) +{ + for (Attack& attack : attacks) + { + attack.origin = vec2_lerp(attack.originPrevious, attack.origin, alpha); + attack.position = vec2_lerp(attack.positionPrevious, attack.position, alpha); + attack.tip = vec2_lerp(attack.tipPrevious, attack.tip, alpha); + attack.angle = angle_lerp(attack.anglePrevious, attack.angle, alpha); + attack.time = math_lerp(attack.timePrevious, attack.time, alpha); + } +} + +void Weapons::attack_hit(Game& game, Attack& attack, int target, Vec2 point) +{ + const AttackDef& def = *attack.def(); + if (def.kind == ATTACK_ROCKET || def.kind == ATTACK_FIREBALL) + { + attack_explode(game, attack); + return; + } + impact_play(game, def.kind); + + // Explosions deal full damage at their centre, down to a fraction at the edge, by the target's nearest point. + float taper = 1; + if (def.kind == ATTACK_EXPLOSION) + { + Rect box = target_box(game, target); + Vec2 nearest = {std::clamp(attack.position.x, box.x, box.x + box.w), + std::clamp(attack.position.y, box.y, box.y + box.h)}; + float edge = std::min(1.0f, vec2_length(nearest - attack.position) / attack.stats.range); + taper = 1 - edge * (1 - SPLASH_EDGE); + } + int damage = std::max(1.0f, std::round(attack.stats.damage * multiplier_get(attack, point) * taper)); + bool isHeavy = damage >= HEAVY_DAMAGE; + SoundId sound = hit_sound_get(attack.weapon >= 0 ? WEAPON_DEFS[attack.weapon].hit : HIT_BLUNT, isHeavy); + + attack.hitsLeft--; + attack.hitCooldown = HIT_INTERVAL; + bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW || + def.kind == ATTACK_WISP; + attack.isDead |= isShot && attack.hitsLeft <= 0; + if (def.kind == ATTACK_HOOK) + hook_attach(game, attack); + // Uppercuts knock the way they rise. + Rect box = target_box(game, target); + Vec2 source = def.kind == ATTACK_EXPLOSION ? attack.position : game.fighters[attack.owner].center_get(); + if (def.kind == ATTACK_UPPERCUT) + source = Vec2{box.x + box.w / 2, box.y + box.h / 2} - vec2_polar(attack.angle, 1); + damage_deal(game, + { + .point = point, + .source = source, + .hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0, + .knock = attack.isGentle ? 0 : knock_get(def.kind), + .amount = damage, + .owner = attack.owner, + .target = target, + .sound = sound, + }); +} + +// Hurts a target with all the feedback: sparks, a number, shake that grows with the damage and a sound. The attacker +// freezes in hitstop, its Boxing Glove adds to the knock, and its Embiggener to the damage (a third of its growth). +// Dummies flash and wobble, blocks crack and break, and fighters take the damage themselves. In a race, fighters only +// get knocked about, so there's no number. +void Weapons::damage_deal(Game& game, Damage damage) +{ + Fighter& attacker = game.fighters[damage.owner]; + Rect box = target_box(game, damage.target); + float embiggen = attacker.trinket_get(TRINKET_EMBIGGENER, 1); + damage.amount = std::max(1, (int)std::round(damage.amount * (1 + (embiggen - 1) * EMBIGGEN_DAMAGE))); + float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; + attacker.hitstop = std::max(attacker.hitstop, damage.hitstop); + damage.knock *= attacker.trinket_get(TRINKET_BOXING, 1); + if (damage.target < MAX_FIGHTERS) + game.fighters[damage.target].damage_take(game, damage); + else + { + Dummy& dummy = game.world.dummies[damage.target - MAX_FIGHTERS]; + dummy.flash = 1; + dummy.wobbleSpeed += side * WOBBLE_IMPULSE * damage.amount; + dummy.health -= damage.amount; + if (dummy.tile >= 0 && dummy.health <= 0) + game.world.block_break(game, dummy, damage.owner); + } + + Burst burst = HIT_BURST; + burst.count += HIT_PARTICLES_PER_DAMAGE * damage.amount; + game.effects.burst(burst, damage.point, side > 0 ? 0 : PI, game.color_get(damage.owner)); + game.effects.shake_add(damage.amount * SHAKE_PER_DAMAGE); + game.audio.play(damage.sound); + if (damage.target < MAX_FIGHTERS && game.match.is_racing()) + return; + game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); + game.effects.dps_record(damage.amount); +} + +// Pebble Helmet: touching another fighter or a dummy (not a block) hurts it, by the level's damage times the usual +// bonus for the fighter's speed, at most once every CONTACT_INTERVAL. +void Weapons::contact_update(Game& game, Fighter& fighter) +{ + float base = fighter.trinket_get(TRINKET_PEBBLE, 0); + fighter.contactCooldown -= TICK; + if (base <= 0 || !fighter.isAlive || fighter.hitstop > 0 || fighter.contactCooldown > 0) + return; + Rect box = fighter.body.box; + int targets = MAX_FIGHTERS + game.world.dummies.size(); + for (int target = 0; target < targets; target++) + { + bool isFighter = target < MAX_FIGHTERS; + if (isFighter && (target >= game.fighterCount || target == fighter.index || !game.fighters[target].isAlive || + !is_hostile(game, fighter.index, target))) + continue; + if (!isFighter && game.world.dummies[target - MAX_FIGHTERS].tile >= 0) + continue; + Rect other = target_box(game, target); + if (!is_rect_overlapping(box, other)) + continue; + float speed = vec2_length(fighter.body.velocity); + float bonus = 1 + (SPEED_DAMAGE + fighter.trinket_get(TRINKET_SPEEDOMETER, 0)) * speed / SPEED_UNIT; + int damage = std::max(1.0f, std::round(base * bonus)); + Vec2 point = {(std::max(box.x, other.x) + std::min(box.x + box.w, other.x + other.w)) / 2, + (std::max(box.y, other.y) + std::min(box.y + box.h, other.y + other.h)) / 2}; + damage_deal( + game, { + .point = point, + .source = fighter.center_get(), + .hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0, + .knock = CONTACT_KNOCK, + .amount = damage, + .owner = fighter.index, + .target = target, + .sound = hit_sound_get(HIT_BLUNT, damage >= HEAVY_DAMAGE), + }); + fighter.contactCooldown = CONTACT_INTERVAL; + return; + } +} + +// Every fighter's hovering wisps share one orbit around it: the oldest fill the inner ring, as many as fit round it +// WISP_SPACING apart, and the rest the rings beyond, so a crowd of wisps makes concentric rings. Each ring spreads its +// wisps evenly and spins the other way to the one inside it. +void Weapons::orbits_update(Game& game) +{ + orbitClock = std::fmod(orbitClock + TICK, 2 * PI / WISP_SPIN); + std::vector hovering; + for (int i = 0; i < game.fighterCount; i++) + { + float scale = game.fighters[i].scale; + hovering.clear(); + for (Attack& attack : attacks) + if (attack.owner == i && attack.def()->kind == ATTACK_WISP && !attack.isDead && + (attack.isGathering || attack.time < attack.duration)) + hovering.push_back(&attack); + int placed = 0; + for (int ring = 0; placed < (int)hovering.size(); ring++) + { + float radius = (WISP_RING + ring * WISP_RING_GAP) * scale; + int count = + std::min(std::max(1, (int)(2 * PI * radius / (WISP_SPACING * scale))), (int)hovering.size() - placed); + float spin = (ring % 2 == 0 ? 1 : -1) * WISP_SPIN * orbitClock; + for (int k = 0; k < count; k++) + { + hovering[placed + k]->orbitRadius = radius; + hovering[placed + k]->orbitAngle = -PI / 2 + spin + 2 * PI * k / count; + } + placed += count; + } + } +} + +// Letting go of attack sends every wisp gathered in the fighter's orbit at the cursor, oldest first, one after +// another, all of them within WISP_RELEASE_SPAN however many there are. +void Weapons::wisps_release(Fighter& fighter) +{ + int count = 0; + for (Attack& attack : attacks) + count += attack.owner == fighter.index && attack.isGathering && !attack.isDead; + float step = std::min(WISP_LAUNCH_STAGGER, WISP_RELEASE_SPAN / std::max(1, count)); + int launched = 0; + for (Attack& attack : attacks) + { + if (attack.owner != fighter.index || !attack.isGathering || attack.isDead) + continue; + attack.isGathering = false; + attack.duration = attack.time + launched++ * step; + } +} + +// Grimoire wisps: glide out to their place in the fighter's orbit (see orbits_update) and circle with it until their +// moment comes, then set off toward wherever the fighter's cursor is right then (plus their spread) and drift only +// very slightly after it (not once they're on top of it), fading out at the end of their range or against a wall (or +// ricocheting off it). They hit anything they touch from the start, orbiting included, and drift through platforms. +void Weapons::wisp_move(Game& game, Attack& attack) +{ + Fighter& fighter = game.fighters[attack.owner]; + attack.radius = WISP_RADIUS * attack.scale; + if (attack.isGathering || attack.time < attack.duration) + { + Vec2 place = vec2_polar(attack.orbitAngle, attack.orbitRadius); + attack.orbit = attack.orbit + (place - attack.orbit) * std::min(1.0f, WISP_GLIDE * TICK); + attack.position = fighter.center_get() + attack.orbit; + attack.origin = attack.position; + return; + } + + Vec2 toward = fighter.controls.cursor - attack.position; + if (attack.velocity.x == 0 && attack.velocity.y == 0) + { + attack.angle += vec2_angle(toward); + attack.velocity = vec2_polar(attack.angle, attack.stats.speed); + game.audio.play(SOUND_WISP); + } + else if (vec2_length(toward) > WISP_HOME_MIN) + { + float turn = std::remainder(vec2_angle(toward) - attack.angle, 2 * PI); + attack.angle += std::clamp(turn, -WISP_TURN * TICK, WISP_TURN * TICK); + attack.velocity = vec2_polar(attack.angle, vec2_length(attack.velocity)); + } + + Vec2 next = attack.position + attack.velocity * TICK; + if (game.world.is_solid_at(next) && attack.bounces > 0) + { + ricochet(game, attack, next); + return; + } + attack.length += vec2_length(next - attack.position); + attack.position = next; + bool isBlocked = game.world.is_solid_at(attack.position); + if (!isBlocked && attack.length < attack.stats.range && attack.time < WISP_LIFETIME) + return; + attack.isDead = true; + if (isBlocked) + impact_play(game, ATTACK_PROJECTILE); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); +} + +// Grimoire teleport: sparkles round the fighter while it gathers, then moves them to the spot marked when it was cast +// (backed off again if their box no longer fits), keeping all their momentum, with a blast there that hurts without +// knocking anyone about. Echoes only make the blast. +void Weapons::teleport_move(Game& game, Attack& attack) +{ + Fighter& fighter = game.fighters[attack.owner]; + attack.radius = 0; + attack.hitsLeft = 0; + if (attack.time < attack.duration) + { + if (!attack.isCopy) + game.effects.burst(WARP_BURST, fighter.center_get(), 0, game.color_get(attack.owner)); + return; + } + + attack.isDead = true; + Vec2 spot = attack.position; + if (!attack.isCopy && !attack.isOffhand && fighter.isAlive) + { + game.effects.burst(PORTAL_BURST, fighter.center_get(), 0, game.color_get(attack.owner)); + spot = teleport_spot(game, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); + fighter.teleport_to(spot); + game.effects.shake_add(attack.def()->shake); + } + blast_spawn(game, { + .position = spot, + .area = attack.stats.area * attack.scale, + .damage = attack.stats.damage, + .owner = attack.owner, + .sound = SOUND_TELEPORT, + .weapon = attack.weapon, + .isGentle = true, + .isSecondary = attack.isSecondary, + }); +} + +// The Grimoire's teleport, drawn rather than sprited: a faint ring where its blast will go with a core swelling inside +// it while it gathers. (Its wisps are drawn with the other attacks.) +void Weapons::magic_draw(Game& game) +{ + for (Attack& attack : attacks) + { + AttackKind kind = attack.def()->kind; + Color color = tint_get(game, attack); + if (kind == ATTACK_TELEPORT && !attack.isDead) + { + float progress = std::min(1.0f, attack.time / attack.duration); + float area = attack.stats.area * attack.scale; + game.renderer.ring_draw(attack.position, area, color_fade(color, TELEPORT_RING_ALPHA)); + game.renderer.circle_draw(attack.position, area * TELEPORT_CORE * progress, color_fade(color, progress)); + } + } +} + +// Tesla Coil: a web of lightning between every weapon the fighter has out in the world (anything but explosions). +// Each piece arcs to its nearest few neighbours within reach, so everything nearby is connected without the +// links growing out of hand for rapid-fire weapons. +std::vector Weapons::chain_get(Game& game, Fighter& fighter) +{ + std::vector links; + if (!fighter.is_trinket_equipped(TRINKET_TESLA)) + return links; + + float range = TESLA_RANGES[fighter.trinket_level_get(TRINKET_TESLA)]; + std::vector pieces; + for (Attack& attack : attacks) + if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION) + pieces.push_back(attack.position); + + int count = pieces.size(); + std::vector isLinked(count * count, false); + std::vector> near; + for (int i = 0; i < count; i++) + { + near.clear(); + for (int j = 0; j < count; j++) + { + float distance = vec2_length(pieces[j] - pieces[i]); + if (j != i && distance <= range) + near.push_back({distance, j}); + } + int reach = std::min((int)near.size(), TESLA_LINKS); + std::partial_sort(near.begin(), near.begin() + reach, near.end()); + for (int k = 0; k < reach; k++) + { + int j = near[k].second; + if (isLinked[std::min(i, j) * count + std::max(i, j)]) + continue; + isLinked[std::min(i, j) * count + std::max(i, j)] = true; + links.push_back({pieces[i], pieces[j], TESLA_RADIUS}); + } + } + return links; +} + +// Shocks every target a fighter's links touch, at most once per interval each. +void Weapons::tesla_update(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + float damage = fighter.trinket_get(TRINKET_TESLA, 0); + for (Capsule& link : chain_get(game, fighter)) + { + for (int target = 0; target < MAX_FIGHTERS + (int)game.world.dummies.size(); target++) + { + Vec2 point; + if (!is_target(game, target, i) || !is_capsule_in_rect(link, target_box(game, target), point)) + continue; + float& shock = target < MAX_FIGHTERS ? game.fighters[target].shockCooldown + : game.world.dummies[target - MAX_FIGHTERS].shockCooldown; + if (shock > 0) + continue; + shock = TESLA_INTERVAL; + damage_deal(game, {.point = point, + .source = fighter.center_get(), + .knock = KNOCK_SLIGHT, + .amount = (int)damage, + .owner = i, + .target = target, + .sound = SOUND_ZAP}); + } + } + } +} + +// A kinked line from one end of the capsule to the other, its radius as the width. Each kink is nudged sideways +// by up to jitter. +static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color color) +{ + Vec2 delta = bolt.to - bolt.from; + Vec2 normal = vec2_normalize({-delta.y, delta.x}); + int kinks = vec2_length(delta) / BOLT_SEGMENT + 1; + Vec2 previous = bolt.from; + + for (int k = 1; k <= kinks; k++) + { + float nudge = k == kinks ? 0 : random_get(-jitter, jitter); + Vec2 point = bolt.from + delta * ((float)k / kinks) + normal * nudge; + renderer.line_draw(previous, point, bolt.radius, color); + previous = point; + } +} + +// Each link is a jagged bolt whose kinks move every frame so it crackles. +void Weapons::tesla_draw(Game& game) +{ + for (int i = 0; i < game.fighterCount; i++) + for (Capsule& link : chain_get(game, game.fighters[i])) + bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i)); +} + +void Weapons::draw(Game& game) +{ + Renderer& renderer = game.renderer; + + for (Attack& attack : attacks) + { + Fighter& fighter = game.fighters[attack.owner]; + Color tint = tint_get(game, attack); + AttackKind kind = attack.def()->kind; + if (kind == ATTACK_HOOK) + { + // The rope runs from the end of the grapple's base in the hand. + Vec2 hand = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE); + strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER}); + } + + if (kind == ATTACK_SPIN || kind == ATTACK_FLAIL) + { + Vec2 hand = fighter.hand_get(); + Vec2 delta = attack.position - hand; + int links = vec2_length(delta) / CHAIN_SPACING; + for (int i = 1; i < links; i++) + game.sprites.effects.draw(renderer, "Chain", 0, + {.color = tint, + .position = hand + delta * ((float)i / links), + .flash = fill_get(strength_get(attack, attack.tip))}); + } + + float progress = attack.time / attack.duration; + if (kind == ATTACK_BEAM) + { + float width = (1 - progress) * attack.scale; + strength_line_draw(game, attack, {{attack.position, attack.tip}, BEAM_OUTER * width, BEAM_INNER * width}); + } + else if (kind == ATTACK_WHIP) + { + // A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite + // rides the end of the whip, pointing the way it runs. + Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip))); + float fade = attack.isCharged ? progress : std::max(0.0f, (progress - WHIP_UNFURL) / (1 - WHIP_UNFURL)); + float width = (1 - fade) * attack.scale; + std::vector points = + attack.isCharged ? std::vector{attack.position, attack.tip} : lash_get(attack); + Vec2 end = points.back(); + Vec2 before = points[points.size() - 2]; + strength_line_draw(game, attack, + {points, WHIP_OUTER * width, WHIP_INNER * width, attack.isCharged ? 0 : WHIP_JITTER}); + game.sprites.effects.draw(renderer, "Whip Tip", 0, + {.color = color_fade(tint, 1 - fade), + .position = end + shake, + .scale = {attack.scale, attack.scale}, + .angle = vec2_angle(end - before)}); + } + else if (kind != ATTACK_EXPLOSION) + attack_pose(game, attack).draw(renderer); + + // A faint ring shows how far anything that explodes would reach, and pips in the shot's colour circle it, one + // for each ricochet it has left. + if (kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_MINE) + renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA)); + for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !attack.isAttached; i++) + { + float angle = attack.time * PIP_SPIN + 2 * PI * i / attack.bounces; + Vec2 pip = attack.position + vec2_polar(angle, attack.radius + PIP_GAP); + renderer.rect_draw({std::round(pip.x - PIP_SIZE / 2), std::round(pip.y - PIP_SIZE / 2), PIP_SIZE, PIP_SIZE}, + tint); + } + } + magic_draw(game); + tesla_draw(game); +} + +// A very faint line along each path the held weapon's attack would take if fired now (one per Splitter copy), out to +// the first wall it can't ricochet off (Ricochet bounces it off as many as the shot would) or the attack's reach: +// curving under gravity for arrows, straight for other shots, throws, beams, lashes, mines and hooks. Anything that +// explodes rings the blast it would make where the path ends (its reach counts from the hand). Melee, flames and flails +// are left out, as their paths aren't lines. Every path fades out over the last quarter of its length as drawn, so a +// path cut short by a wall fades too. +void Weapons::trajectory_draw(Game& game, Fighter& fighter) +{ + Color base = game.color_get(fighter.index); + auto faded = [&](float along, float length) { + float fade = std::clamp((length - along) / (length * TRAJECTORY_TAPER), 0.0f, 1.0f); + return color_fade(base, TRAJECTORY_ALPHA * fade); + }; + int key = fighter.weapon_key(fighter.slot); + if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, false))) + return; + const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]]; + const LevelDef& level = fighter.level_get(key); + bool isCharged = fighter.charge >= 1; + const AttackDef& def = isCharged ? weapon.charged : weapon.normal; + AttackKind kind = def.kind; + AttackStats stats = isCharged ? level.charged : level.normal; + if (weapon.isDrawn) + stats = stats_blend(level, fighter.charge); + if (kind == ATTACK_TELEPORT) + { + Vec2 spot = teleport_spot(game, fighter, fighter.controls.cursor, stats.range); + float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1); + Vec2 from = fighter.center_get(); + float length = vec2_length(spot - from); + for (float travelled = 0; travelled < length; travelled += TRAJECTORY_STEP) + game.renderer.line_draw(from + (spot - from) * (travelled / length), + from + (spot - from) * (std::min(length, travelled + TRAJECTORY_STEP) / length), 1, + faded(travelled, length)); + game.renderer.ring_draw(spot, area, color_fade(base, BLAST_RING_ALPHA)); + return; + } + bool isArrow = kind == ATTACK_ARROW; + bool isStraight = kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ROCKET || + kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_WHIP || kind == ATTACK_MINE || + kind == ATTACK_BOOMERANG || (kind == ATTACK_HOOK && !isCharged); + if (!isArrow && !isStraight) + return; + + // Each Splitter copy gets its own path, fanned like the copies. Straight paths step along the aim one unit at a + // time. Arrows fly at their real speed so gravity bends them. + int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); + float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1) * (splits > 1 ? SPLIT_SCALE : 1); + float reach = std::min(stats.range, TRAJECTORY_REACH); + std::vector steps; + for (int split = 0; split < splits; split++) + { + float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0)) : 0; + Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); + float speed = stats.speed * fighter.trinket_get(TRINKET_ACCELERATOR, 1) + push_get(fighter, aim); + Vec2 velocity = isArrow ? aim * speed : aim; + Vec2 point = fighter.hand_get() + aim * MUZZLE_DISTANCE; + int bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0; + steps.clear(); + bool isEnded = stats.range <= TRAJECTORY_REACH; + for (float travelled = area > 0 ? MUZZLE_DISTANCE : 0; travelled < reach; travelled += TRAJECTORY_STEP) + { + float time = TRAJECTORY_STEP / std::max(1.0f, vec2_length(velocity)); + Vec2 next = point + velocity * time; + if (game.world.is_blocking_at(next)) + { + isEnded |= bounces <= 0; + if (bounces-- <= 0) + break; + velocity = reflect_get(game, point, next, velocity); + continue; + } + velocity.y += isArrow ? ARROW_GRAVITY * time : 0; + steps.push_back(point); + point = next; + } + steps.push_back(point); + for (int i = 0; i + 1 < (int)steps.size(); i++) + game.renderer.line_draw(steps[i], steps[i + 1], 1, faded(i, steps.size() - 1)); + if (area > 0 && isEnded) + game.renderer.ring_draw(point, area, color_fade(base, BLAST_RING_ALPHA)); + } +} + +// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even. +static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color) +{ + Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY}; + for (int i = 0; i < count; i++) + { + Capsule& shape = shapes[i]; + bounds.x = std::min({bounds.x, shape.from.x - shape.radius, shape.to.x - shape.radius}); + bounds.y = std::min({bounds.y, shape.from.y - shape.radius, shape.to.y - shape.radius}); + bounds.w = std::max({bounds.w, shape.from.x + shape.radius, shape.to.x + shape.radius}); + bounds.h = std::max({bounds.h, shape.from.y + shape.radius, shape.to.y + shape.radius}); + } + auto isInside = [&](Vec2 point) { + for (int i = 0; i < count; i++) + { + Vec2 line = shapes[i].to - shapes[i].from; + float length = vec2_dot(line, line); + float t = length > 0 ? std::clamp(vec2_dot(point - shapes[i].from, line) / length, 0.0f, 1.0f) : 0; + if (vec2_length(point - (shapes[i].from + line * t)) <= shapes[i].radius) + return true; + } + return false; + }; + for (float y = std::floor(bounds.y); y <= bounds.h; y++) + { + float start = -INFINITY; + for (float x = std::floor(bounds.x); x <= bounds.w + 1; x++) + { + bool isIn = x <= bounds.w && isInside({x + 0.5f, y + 0.5f}); + if (isIn && start == -INFINITY) + start = x; + if (!isIn && start != -INFINITY) + { + renderer.rect_draw({start, y, x - start, 1}, color); + start = -INFINITY; + } + } + } +} + +// The Display Collision setting: every attack's hit shapes as the hit test sees them, and the boxes fighters and +// dummies are hit in, see-through in their owner's colour. +void Weapons::collision_draw(Game& game) +{ + Renderer& renderer = game.renderer; + for (Attack& attack : attacks) + { + Capsule shapes[WHIP_RAYS]; + int count = shapes_get(game, attack, shapes); + capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA)); + } + for (int i = 0; i < game.fighterCount; i++) + if (game.fighters[i].isAlive) + renderer.rect_draw(game.fighters[i].body.box, color_fade(game.color_get(i), COLLISION_ALPHA)); + for (Dummy& dummy : game.world.dummies) + if (dummy.tile < 0) + renderer.rect_draw(dummy.box, color_fade(WHITE, COLLISION_ALPHA)); +} + +// Explosions draw before the player, so their filled centre never hides anyone standing in the blast. +void Weapons::blast_draw(Game& game) +{ + for (Attack& attack : attacks) + { + if (attack.def()->kind != ATTACK_EXPLOSION) + continue; + float progress = attack.time / attack.duration; + float radius = attack.stats.range * std::min(1.0f, progress * EXPLOSION_GROW); + Color color = color_dim(owner_color_get(game, attack), attack.bonus); + float fill = fill_get(attack.bonus); + Vec2 center = attack.position + jitter_get(fill); + game.renderer.circle_draw(center, radius, color_fade(color, 1 - progress)); + if (attack.time > EXPLOSION_FLASH) + game.renderer.circle_draw(center, radius - EXPLOSION_OUTLINE, + color_fade(color_shade(color, fill), 1 - progress)); + } +} + +// How a sprite-based attack looks right now: its weapon, or the projectile or effect it fired. Shared by the live +// attack and its afterimages. +Pose Weapons::attack_pose(Game& game, Attack& attack) +{ + const AttackDef& def = *attack.def(); + float progress = attack.time / attack.duration; + float side = attack.isLeft ? -1 : 1; + Anm2& effects = game.sprites.effects; + Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name}; + Draw& draw = pose.placement; + draw = {.position = attack.position, .scale = {attack.scale, attack.scale * side}, .angle = attack.angle}; + Color tint = tint_get(game, attack); + draw.color = tint; + draw.flash = fill_get(strength_get(attack, attack.tip)); + draw.position = draw.position + jitter_get(draw.flash); + + switch (def.kind) + { + case ATTACK_SWING: + draw.position = attack.origin; + break; + case ATTACK_BOOMERANG: + draw.scale.y = attack.scale; + draw.angle = attack.time * BOOMERANG_SPIN; + break; + case ATTACK_SLASH: + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + pose = {&effects, "Slash", draw}; + break; + case ATTACK_ARROW: + { + float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f); + draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1); + pose = {&effects, "Arrow", draw}; + break; + } + case ATTACK_ROCKET: + pose = {&effects, "Rocket", draw}; + break; + case ATTACK_PUNCH: + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + pose = {&effects, "Punch", draw}; + break; + case ATTACK_UPPERCUT: + // The fist is drawn rising, so it stands upright when aimed straight ahead and leans with the launch. + // Facing left mirrors it. + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + draw.scale = {attack.scale * side, attack.scale}; + draw.angle = attack.angle + PI / 2; + pose = {&effects, "Uppercut", draw, attack.time * effects.fps}; + break; + case ATTACK_BULLET: + pose = {&effects, "Slug", draw}; + break; + case ATTACK_PROJECTILE: + pose = {&effects, "Bullet", draw}; + break; + case ATTACK_HOOK: + pose.animation = "Grapple Tip"; + break; + case ATTACK_MINE: + draw.scale.y = attack.scale; + draw.angle = 0; + if (is_mine_armed(attack) && std::fmod(attack.time, MINE_BLINK) < MINE_BLINK_ON) + draw.flash = 1; + pose = {&effects, "Mine", draw}; + break; + case ATTACK_FLAME: + case ATTACK_FIREBALL: + { + // Flames flicker through their animation, the spread jitter staggering them. Loose flames grow and fade + // out. Fireballs point their tip back along the path. + bool isFlame = def.kind == ATTACK_FLAME; + float size = attack.scale * (isFlame ? FLAME_SHRINK + FLAME_GROW * progress : 1); + draw.scale = {size, size}; + draw.angle = isFlame ? 0 : attack.angle - PI / 2; + if (isFlame) + draw.color = color_fade(tint, std::min(1.0f, (1 - progress) * FLAME_FADE)); + pose = {&effects, "Flame", draw, (attack.time + attack.aimAngle * FLAME_STAGGER) * effects.fps}; + break; + } + case ATTACK_SPIN: + case ATTACK_FLAIL: + pose = {&effects, "Flail Ball", draw}; + break; + case ATTACK_WISP: + { + // Wisps burn like little flames while they hover, tails flickering upward, then streak head-first along + // their path, each a little out of step with the rest. + bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0; + draw.scale = {attack.scale, attack.scale}; + draw.angle = isFlying ? attack.angle : PI / 2; + pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps}; + break; + } + case ATTACK_TELEPORT: + // Drawn as its marker and sparkles instead. + pose.art = nullptr; + break; + default: + break; + } + return pose; +} + +// Latches the hook where it is and sets the rope to its fighter's current distance. Charged hooks start pulling. +void Weapons::hook_attach(Game& game, Attack& attack) +{ + Fighter& fighter = game.fighters[attack.owner]; + attack.isAttached = true; + attack.isPulling = attack.isCharged; + attack.hitsLeft = 0; + attack.length = std::min(vec2_length(fighter.center_get() - attack.position), attack.stats.range); + game.audio.play(SOUND_LATCH); + game.effects.shake_add(attack.def()->shake); + game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); +} + +// Traces instant attacks for one piece: beams march to the first wall and reflect off any Ricochet bounces, +// whips lash from the hand to the first wall (pulling their wielder toward it, and charged ones blowing up at the +// tip), and charged hooks plunge into +// the first grabbable surface or target (dummy or enemy fighter). +void Weapons::piece_launch(Game& game, Attack& piece) +{ + if (piece.def()->kind == ATTACK_WHIP) + { + auto isBlocked = [&](Vec2 point) { + return game.world.is_blocking_at(point); + }; + piece.position = piece.origin; + piece.tip = ray_march(piece.origin, piece.angle, piece.stats.range, isBlocked); + Vec2 lash = piece.tip - piece.origin; + if (!piece.isCopy && vec2_length(lash) < piece.stats.range - RAY_STEP) + game.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL * + game.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1)); + if (piece.isCharged) + blast_spawn(game, { + .position = piece.tip, + .area = piece.stats.area * piece.scale / piece.stats.scale, + .damage = piece.stats.damage, + .owner = piece.owner, + .sound = SOUND_NONE, // the charged crack already carries it + .weapon = piece.weapon, + .isSecondary = piece.isSecondary, + }); + } + + if (piece.def()->kind == ATTACK_BEAM) + { + auto isBlocked = [&](Vec2 point) { + return game.world.is_blocking_at(point); + }; + piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); + game.effects.burst(SPARK_BURST, piece.tip, piece.angle + PI, game.color_get(piece.owner)); + + Attack segment = piece; + for (int i = 0; i < piece.bounces; i++) + { + segment.travelled += vec2_length(segment.tip - segment.position); + Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); + segment.angle = vec2_angle(reflect_get(game, before, segment.tip, vec2_polar(segment.angle, 1))); + segment.position = before; + segment.tip = ray_march(segment.position, segment.angle, piece.stats.range, isBlocked); + segment.ricochets++; + attacks.push_back(segment); + game.effects.burst(SPARK_BURST, segment.tip, segment.angle + PI, game.color_get(piece.owner)); + } + } + + if (piece.def()->kind == ATTACK_HOOK && piece.isCharged) + { + auto isBlocked = [&](Vec2 point) { + for (int target = 0; target < MAX_FIGHTERS + (int)game.world.dummies.size(); target++) + if (is_target(game, target, piece.owner) && is_point_in_rect(point, target_box(game, target))) + return true; + return game.world.is_blocking_at(point); + }; + piece.position = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); + if (game.world.is_blocking_at(piece.position)) + hook_attach(game, piece); + } +} + +// Bursts a rocket, mine or fireball into a pushing explosion, keeping the damage raised by any ricochets and cut by +// how far it flew. The +// blast grows with trinkets but not with the attack's own size, so a big fireball keeps its listed area. +void Weapons::attack_explode(Game& game, Attack& attack) +{ + attack.isDead = true; + attack.hitsLeft = 0; + blast_spawn(game, { + .position = attack.position, + .area = blast_area_get(attack), + .bonus = strength_get(attack, attack.position), + .damage = attack.stats.damage * multiplier_get(attack, attack.position), + .owner = attack.owner, + .sound = SOUND_EXPLOSION, + .weapon = attack.weapon, + .isPushing = true, + .isSecondary = attack.isSecondary, + }); +} + +// Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every +// fighter away from the centre, harder the closer they are. +void Weapons::blast_spawn(Game& game, Blast blast) +{ + Attack attack = { + .stats = {.damage = blast.damage, .range = blast.area}, + .origin = blast.position, + .position = blast.position, + .tip = blast.position, + .bonus = blast.bonus, + .duration = EXPLOSION_DEF.duration, + .hitsLeft = 1, + .owner = blast.owner, + .weapon = blast.weapon, + .isExplosion = true, + .isGentle = blast.isGentle, + .isSecondary = blast.isSecondary, + }; + attack_remember(attack); + pending.push_back(attack); + + float reach = blast.area * KNOCKBACK_REACH; + for (int i = 0; i < game.fighterCount; i++) + { + Fighter& fighter = game.fighters[i]; + Vec2 away = fighter.center_get() - blast.position; + float distance = vec2_length(away); + // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback. + float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get(); + if (blast.isPushing && fighter.isAlive && distance < reach) + fighter.impulse_add(vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * + (1 - distance / reach) * absorb); + } + + game.audio.play(blast.sound); + game.effects.shake_add(EXPLOSION_DEF.shake); + game.effects.burst(BLAST_BURST, blast.position, 0, game.color_get(blast.owner)); +} + +// The held weapon's turn away from the aim: a quick eased sweep from the kick to the follow-through, which then +// settles back to the aim. +float Weapons::kick_get(Fighter& fighter) +{ + if (fighter.kickTime < KICK_SWEEP) + { + float t = fighter.kickTime / KICK_SWEEP; + return math_lerp(fighter.kickFrom, fighter.kickTo, t * (2 - t)); + } + return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK); +} + +// A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips +// down and back like a pump, then swings back to the aim just as it's ready. +static void reload_pose(Fighter& fighter, int key, float cooldown, Draw& draw) +{ + if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0) + return; + float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); + float t = std::clamp((1 - cooldown / total - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f); + float weight = math_sin(PI * t); + draw.angle = angle_lerp(draw.angle, -PI / 2, weight); + draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP); +} + +// How far a fighter's weapons fill in with its colour: with the charge as it builds, or the speed bonus if that's more, +// then once the charge is ready blinking between full and the speed bonus alone. +float Weapons::charge_fill_get(Fighter& fighter) +{ + float buff = buff_get(fighter); + if (fighter.charge >= 1) + return fighter.is_flashing() ? 1.0f : buff; + return std::max(buff, fighter.charge); +} + +// Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it +// charges (see charge_fill_get) and shakes harder the further the charge has got. A charging mine weapon holds up the +// mine it's about to place instead. +void Weapons::held_draw(Game& game, Fighter& fighter) +{ + int held = fighter.weapon_key(fighter.slot); + if (held < 0 || (is_busy(fighter, held) && !tether_get(fighter, held, false))) + return; + + float side = fighter.is_left() ? -1 : 1; + Draw draw = { + .color = game.color_get(fighter.index), + .position = fighter.hand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), + .scale = {1, side}, + .angle = vec2_angle(fighter.aim) + kick_get(fighter) * side, + .flash = charge_fill_get(fighter), + }; + if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE) + { + draw.scale = {1, 1}; + draw.angle = 0; + game.sprites.effects.draw(game.renderer, "Mine", 0, draw); + return; + } + reload_pose(fighter, held, fighter.cooldowns[held], draw); + hand_draw(game, fighter, held, false, draw); +} + +// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it. +void Weapons::offhand_draw(Game& game, Fighter& fighter) +{ + int off = fighter.off_slot_get(); + off = off < 0 ? -1 : fighter.weapon_key(off); + if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 || + (is_busy(fighter, off) && !tether_get(fighter, off, true))) + return; + + float side = fighter.is_left() ? -1 : 1; + Draw draw = { + .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), + .position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), + .scale = {1, side}, + .angle = vec2_angle(fighter.aim), + .flash = charge_fill_get(fighter), + }; + reload_pose(fighter, off, fighter.cooldowns[off], draw); + hand_draw(game, fighter, off, true, draw); +} + +// The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it. +Attack* Weapons::tether_get(Fighter& fighter, int key, bool isOffhand) +{ + for (Attack& attack : attacks) + { + AttackKind kind = attack.def()->kind; + if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key && + attack.isOffhand == isOffhand && !attack.isCopy && !attack.isDead) + return &attack; + } + return nullptr; +} + +// Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base (" Base"), pointing +// down the lash or rope. +void Weapons::hand_draw(Game& game, Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw) +{ + Attack* tether = tether_get(fighter, weaponSlot, isOffhand); + std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name; + if (tether) + { + Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; + draw.angle = vec2_angle(end - draw.position); + animation += " Base"; + } + // A weapon without held art yet shows its icon, small and upright. + if (!game.sprites.weapons.animation_get(animation.c_str())) + { + draw.scale = {HELD_ICON_SCALE * (draw.scale.y < 0 ? -1 : 1), HELD_ICON_SCALE}; + draw.angle = 0; + game.sprites.icons.draw(game.renderer, animation.c_str(), 0, draw); + return; + } + game.sprites.weapons.draw(game.renderer, animation.c_str(), 0, draw); +} + +// The fighter's own attacks it can stand on or bump into: stuck arrows and charged boomerangs in flight. +std::vector Weapons::solids_get(Fighter& fighter) +{ + std::vector solids; + for (Attack& attack : attacks) + { + if (attack.owner != fighter.index || attack.isDead) + continue; + AttackKind kind = attack.def()->kind; + if (kind == ATTACK_BOOMERANG && attack.isCharged) + { + float size = BOOMERANG_RADIUS * attack.scale; + solids.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2}); + } + if (kind == ATTACK_ARROW && attack.isAttached) + { + Vec2 middle = attack.position - vec2_polar(attack.angle, ARROW_SPAN / 2); + Vec2 half = {std::abs(math_cos(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS, + std::abs(math_sin(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS}; + solids.push_back({middle.x - half.x, middle.y - half.y, half.x * 2, half.y * 2}); + } + } + return solids; +} + +// Whether the fighter has a flail spinning around it. +bool Weapons::is_spinning(Fighter& fighter) +{ + for (Attack& attack : attacks) + if (attack.owner == fighter.index && attack.def()->kind == ATTACK_SPIN && !attack.isDead) + return true; + return false; +} + +// The latched grapple hook the fighter is hanging from, if any. +Attack* Weapons::hook_get(Fighter& fighter) +{ + for (Attack& attack : attacks) + if (attack.owner == fighter.index && attack.def()->kind == ATTACK_HOOK && attack.isAttached && !attack.isDead) + return &attack; + return nullptr; +} + +// Removes every attack of one kind the fighter owns at once, like all grapple hooks so Splitter copies drop together. +void Weapons::attacks_release(Fighter& fighter, AttackKind kind) +{ + for (Attack& attack : attacks) + attack.isDead |= attack.owner == fighter.index && attack.def()->kind == kind; +} + +// A fresh press calls thrown weapons back: Charge recalls boomerangs once all are out, Attack or Charge +// reels in a flail ball. Reports whether it did. +bool Weapons::is_recalled(Game& game, Fighter& fighter) +{ + int key = fighter.weapon_key(fighter.slot); + if (key < 0) + return false; + bool isLeft = fighter.is_pressed(CONTROL_ATTACK); + bool isRight = fighter.is_pressed(CONTROL_CHARGE); + bool isBoomerang = + WEAPON_DEFS[fighter.weaponLoadout[key]].normal.kind == ATTACK_BOOMERANG && isRight && is_busy(fighter, key); + bool isFlail = false; + for (Attack& attack : attacks) + isFlail |= attack.def()->kind == ATTACK_FLAIL && attack.owner == fighter.index && attack.key == key && + !attack.isReturning; + isFlail &= isLeft || isRight; + if (!isBoomerang && !isFlail) + return false; + + for (Attack& attack : attacks) + attack.isReturning |= attack.owner == fighter.index && attack.key == key; + return true; +} + +// Swings the thrown ball under gravity on its chain, then lets the world's tile collision move it, +// adding a small bounce and ground friction. Hard landings thud. +void Weapons::flail_move(Game& game, Attack& attack) +{ + Fighter& fighter = game.fighters[attack.owner]; + attack.velocity.y += GRAVITY * TICK; + Vec2 hand = fighter.hand_get(); + Vec2 offset = attack.position + attack.velocity * TICK - hand; + if (vec2_length(offset) > attack.stats.range) + attack.velocity = (hand + vec2_normalize(offset) * attack.stats.range - attack.position) * (1 / TICK); + + float size = FLAIL_SIZE * attack.scale; + Body body = { + .box = {attack.position.x - size / 2, attack.position.y - size / 2, size, size}, + .velocity = attack.velocity, + }; + Vec2 before = body.velocity; + game.world.body_move(body); + bool isThudX = body.velocity.x == 0 && std::abs(before.x) > FLAIL_THUD_SPEED; + bool isThudY = body.velocity.y == 0 && std::abs(before.y) > FLAIL_THUD_SPEED; + if (isThudX || isThudY) + impact_play(game, ATTACK_FLAIL); + + float bounce = + std::min(FLAIL_BOUNCE_MAX, FLAIL_BOUNCE + FLAIL_BOUNCE_PER_RICOCHET * fighter.trinket_get(TRINKET_RICOCHET, 0)); + if (body.velocity.x == 0 && std::abs(before.x) > FLAIL_BOUNCE_MIN) + body.velocity.x = -before.x * bounce; + if (body.velocity.y == 0 && std::abs(before.y) > FLAIL_BOUNCE_MIN) + body.velocity.y = -before.y * bounce; + if (body.isGrounded) + body.velocity.x = + std::clamp(0.0f, body.velocity.x - FLAIL_FRICTION * TICK, body.velocity.x + FLAIL_FRICTION * TICK); + + attack.position = {body.box.x + size / 2, body.box.y + size / 2}; + attack.velocity = body.velocity; +} + +// Anything that leaves the hand (swings, pokes, throws, hooks) takes the weapon out of it. +// A weapon (by its key, see Fighter::weapon_key) is busy once as many are out as its level allows. Splitter and +// Doubler copies don't count. +bool Weapons::is_busy(Fighter& fighter, int key) +{ + int out = 0; + for (Attack& attack : attacks) + { + AttackKind kind = attack.def()->kind; + bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_BOOMERANG || kind == ATTACK_HOOK || + kind == ATTACK_SPIN || kind == ATTACK_FLAIL; + out += attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy && isHeld; + } + return out >= fighter.level_get(key).count; +} diff --git a/src/weapon.h b/src/weapon.h new file mode 100644 index 0000000..a97043a --- /dev/null +++ b/src/weapon.h @@ -0,0 +1,902 @@ +#pragma once + +#include "anm2.h" +#include "audio.h" +#include "renderer.h" +#include +#include + +class Fighter; +struct Dummy; +struct Game; + +constexpr int LEVEL_COUNT = 3; +constexpr int WEAPON_COUNT = 16; +constexpr int SLOT_MAX = 9; +constexpr int SLOTS_DEFAULT = 2; +constexpr float RANGE_INFINITE = std::numeric_limits::infinity(); + +enum AttackKind +{ + ATTACK_ARROW, + ATTACK_BEAM, + ATTACK_BOOMERANG, + ATTACK_BULLET, + ATTACK_DETONATE, + ATTACK_EXPLOSION, + ATTACK_FIREBALL, + ATTACK_FLAIL, + ATTACK_FLAME, + ATTACK_HOOK, + ATTACK_MINE, + ATTACK_POKE, + ATTACK_PROJECTILE, + ATTACK_PUNCH, + ATTACK_ROCKET, + ATTACK_SLASH, + ATTACK_SPIN, + ATTACK_SWING, + ATTACK_TELEPORT, + ATTACK_UPPERCUT, + ATTACK_WHIP, + ATTACK_WISP +}; + +// How one attack behaves, shared by every level. Label names what it fires, for tooltips (a volley adds an s). Spread +// is how far off the aim it may leave, in degrees either side, +// and scatter how far each piece of a volley may stray on top of that. Unused fields stay zero. +struct AttackDef +{ + const char* label; + AttackKind kind; + SoundId sound; + float duration = 0; + float hold = 0; + float scale = 1; + float shake = 0; + float spread = 0; + float scatter = 0; + int hits = 1; +}; + +// One attack's numbers at one level. Range is in pixels: reach, travel distance, beam or rope length. +// Duration, when set, replaces the attack's shared duration (how long flames linger). +// Area is the radius of any explosion. Lunge launches the player along the aim, and a negative lunge kicks them back. +// Scale multiplies the attack's size. Volley is how many fire at once, spread across fan radians around the aim, or +// side by side when fan is zero. +struct AttackStats +{ + float area = 0; + float damage = 0; + float duration = 0; + float fan = 0; + float lunge = 0; + float range = 0; + float scale = 1; + float speed = 0; + int volley = 1; +}; + +// Everything that changes with a weapon's level. Reel is the grapple's Up/Down reeling speed. +// Count is how many of the weapon can be out at once. Limit (0 for none) is how many of the attack being fired can be +// out at once, counting only its own pieces: Splitter copies, Doubler repeats and off-hand ones neither count nor are +// held back, and a volley that would go over fires only as many as fit. +struct LevelDef +{ + AttackStats normal; + AttackStats charged; + float cooldown = 0; + float offCooldown = 0; + float reel = 0; + int count = 1; + int limit = 0; +}; + +constexpr float ARROW_GRAVITY = 500; +constexpr float MINE_ARM_TIME = 0.3f; +constexpr float ROCKET_ACCELERATION = 600; +constexpr float ROCKET_SPEEDUP = 7.2f; + +enum AimStyle +{ + AIM_DIRECT, + AIM_LEAD, + AIM_LOB, + AIM_GROUND +}; + +enum ChargePolicy +{ + CHARGE_NEVER, + CHARGE_SOMETIMES, + CHARGE_ALWAYS +}; + +// How a bot uses a weapon: the distance band it prefers to fight from (it still fires when closer than the minimum), +// when it charges, and how it aims (straight at the target, leading it, lobbing over the drop, or at its feet). +struct BotHint +{ + float rangeMin = 0; + float rangeMax = 0; + ChargePolicy charge = CHARGE_SOMETIMES; + AimStyle aim = AIM_DIRECT; +}; + +// What a hit feels like, which picks its sound: each type has a light and a heavy one. Weapons name theirs, and +// anything else that hurts (slams, Pebble Helmet) is blunt. +enum HitType +{ + HIT_BLUNT, + HIT_SLASH, + HIT_PIERCE, + HIT_LASH, + HIT_BULLET, + HIT_ENERGY, + HIT_FIRE, + HIT_EXPLOSIVE, + HIT_MAGIC, + HIT_TYPE_COUNT +}; + +struct HitSounds +{ + SoundId light; + SoundId heavy; +}; + +inline constexpr HitSounds HIT_SOUNDS[HIT_TYPE_COUNT] = { + {SOUND_HIT_BLUNT, SOUND_HIT_BLUNT_HEAVY}, {SOUND_HIT_SLASH, SOUND_HIT_SLASH_HEAVY}, + {SOUND_HIT_PIERCE, SOUND_HIT_PIERCE_HEAVY}, {SOUND_HIT_LASH, SOUND_HIT_LASH_HEAVY}, + {SOUND_HIT_BULLET, SOUND_HIT_BULLET_HEAVY}, {SOUND_HIT_ENERGY, SOUND_HIT_ENERGY_HEAVY}, + {SOUND_HIT_FIRE, SOUND_HIT_FIRE_HEAVY}, {SOUND_HIT_EXPLOSIVE, SOUND_HIT_EXPLOSIVE_HEAVY}, + {SOUND_HIT_MAGIC, SOUND_HIT_MAGIC_HEAVY}, +}; + +inline SoundId hit_sound_get(HitType type, bool isHeavy) +{ + return isHeavy ? HIT_SOUNDS[type].heavy : HIT_SOUNDS[type].light; +} + +// Hit is the type of hit it lands. Flash is the size of the muzzle flash when it fires, and ejecting weapons throw out +// casings. Kick turns the held +// weapon away from the aim when it attacks, and follow is where that motion sweeps through to +// before settling back. A drawn weapon blends its normal and charged numbers by how far the charge got, +// instead of switching to the charged attack only when full. Reloading weapons pump in the hand while cooling down. +struct WeaponDef +{ + const char* name; + const char* blurb; + const char* chargeBlurb; + const char* flavor = ""; + HitType hit = HIT_BLUNT; + float flash = 0; + float follow = 0; + float kick = 0; + AttackDef normal; + AttackDef charged; + LevelDef levels[LEVEL_COUNT]; + BotHint bot; + bool isDrawn = false; + bool isEjecting = false; + bool isReloading = false; +}; + +inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = + { + { + .name = "Sword", + .blurb = "Slashes towards the cursor.", + .chargeBlurb = "Charge to unleash a huge slash\nthat travels forward.", + .flavor = "Oh, great, ANOTHER swordfighter\nin the roster.", + .hit = HIT_SLASH, + .follow = 1.2f, + .kick = -1.8f, + .normal = {.label = "slash", + .kind = ATTACK_SLASH, + .sound = SOUND_SLASH, + .duration = 0.3f, + .shake = 0.15f, + .spread = 7}, + .charged = {.label = "flying slash", + .kind = ATTACK_SLASH, + .sound = SOUND_SLASH_HEAVY, + .duration = 0.6f, + .shake = 0.45f, + .spread = 5}, + .levels = + { + {.normal = {.damage = 18, .lunge = 70, .range = 23, .speed = 60}, + .charged = {.damage = 36, .lunge = 140, .range = 55, .speed = 150}, + .cooldown = 0.3f}, + {.normal = {.damage = 24, .lunge = 70, .range = 28, .speed = 60}, + .charged = {.damage = 48, .lunge = 140, .range = 65, .speed = 150}, + .cooldown = 0.3f}, + {.normal = {.damage = 30, .lunge = 70, .range = 33, .speed = 60}, + .charged = {.damage = 60, .lunge = 140, .range = 75, .speed = 150}, + .cooldown = 0.3f}, + }, + .bot = {.rangeMax = 48}, + }, + { + .name = "Hammer", + .blurb = "Swings overhead in a wide arc.", + .chargeBlurb = "Charge to grow huge and slam\nthe ground around you.", + .flavor = "Stop! Hammer time!", + .hit = HIT_BLUNT, + .normal = {.label = "swing", + .kind = ATTACK_SWING, + .sound = SOUND_SWING, + .duration = 0.3f, + .shake = 0.3f, + .spread = 6}, + .charged = {.label = "slam", + .kind = ATTACK_SWING, + .sound = SOUND_SWING_HEAVY, + .duration = 0.45f, + .shake = 0.7f, + .spread = 6}, + .levels = + { + {.normal = {.damage = 20, .range = 30}, .charged = {.damage = 40, .range = 64}, .cooldown = 0.5f}, + {.normal = {.damage = 28, .range = 34}, .charged = {.damage = 55, .range = 72}, .cooldown = 0.5f}, + {.normal = {.damage = 34, .range = 38}, .charged = {.damage = 69, .range = 80}, .cooldown = 0.5f}, + }, + .bot = {.rangeMax = 36}, + }, + { + .name = "Lance", + .blurb = "Pokes quickly towards the cursor.", + .chargeBlurb = "Charge to lunge toward the cursor\nwith a harder poke.", + .flavor = "Works best when riding an ostrich.", + .hit = HIT_PIERCE, + .normal = {.label = "poke", .kind = ATTACK_POKE, .sound = SOUND_POKE, .shake = 0.15f, .spread = 4}, + .charged = + {.label = "lunging poke", .kind = ATTACK_POKE, .sound = SOUND_POKE_HEAVY, .shake = 0.4f, .spread = 3}, + .levels = + { + {.normal = {.damage = 12, .range = 90, .speed = 500}, + .charged = {.damage = 24, .lunge = 200, .range = 90, .speed = 500}, + .cooldown = 0.35f}, + {.normal = {.damage = 16, .range = 105, .speed = 600}, + .charged = {.damage = 32, .lunge = 250, .range = 105, .speed = 600}, + .cooldown = 0.35f}, + {.normal = {.damage = 20, .range = 120, .speed = 700}, + .charged = {.damage = 40, .lunge = 300, .range = 120, .speed = 700}, + .cooldown = 0.35f}, + }, + .bot = {.rangeMin = 40, .rangeMax = 140}, + }, + { + .name = "Fist", + .blurb = "Throws a rapid flurry of punches\nwhile held.", + .chargeBlurb = "Charge to launch yourself up\nwith a huge uppercut.", + .flavor = "ORA ORA ORA ORA ORA", + .hit = HIT_BLUNT, + .normal = {.label = "punch", + .kind = ATTACK_PUNCH, + .sound = SOUND_PUNCH, + .duration = 0.12f, + .shake = 0.05f, + .spread = 18}, + .charged = {.label = "uppercut", + .kind = ATTACK_UPPERCUT, + .sound = SOUND_UPPERCUT, + .duration = 0.35f, + .shake = 0.5f, + .spread = 5}, + .levels = + { + {.normal = {.damage = 14, .range = 34}, + .charged = {.damage = 50, .lunge = 420, .scale = 1}, + .cooldown = 0.1f}, + {.normal = {.damage = 16, .range = 40}, + .charged = {.damage = 69, .lunge = 500, .scale = 1.4f}, + .cooldown = 0.07f}, + {.normal = {.damage = 19, .range = 46}, + .charged = {.damage = 88, .lunge = 580, .scale = 1.8f}, + .cooldown = 0.045f}, + }, + .bot = {.rangeMax = 36}, + }, + { + .name = "Flail", + .blurb = "Spins the spiked ball around you,\nlightening your fall.", + .chargeBlurb = + "Charge to hurl the ball out on its\nchain. It hits whatever it touches\nuntil you attack or " + "charge to\nreel it in.", + .flavor = "How did anyone use these without\nhurting themselves?", + .hit = HIT_BLUNT, + .normal = {.label = "spin", + .kind = ATTACK_SPIN, + .sound = SOUND_FLAIL, + .duration = 0.4f, + .shake = 0.15f, + .spread = 8, + .hits = 99}, + .charged = {.label = "ball throw", + .kind = ATTACK_FLAIL, + .sound = SOUND_FLAIL_HEAVY, + .shake = 0.3f, + .spread = 6, + .hits = 99}, + .levels = + { + {.normal = {.damage = 13, .range = 33, .scale = 1}, + .charged = {.damage = 40, .range = 150, .scale = 1, .speed = 450}, + .cooldown = 0.5f}, + {.normal = {.damage = 18, .range = 41, .scale = 1.5f}, + .charged = {.damage = 50, .range = 175, .scale = 1.5f, .speed = 450}, + .cooldown = 0.5f}, + {.normal = {.damage = 22, .range = 47, .scale = 2}, + .charged = {.damage = 60, .range = 200, .scale = 2, .speed = 450}, + .cooldown = 0.5f}, + }, + .bot = {.rangeMax = 60}, + }, + { + .name = "Whip", + .blurb = "Lashes out to a long reach\nfor a light sting.", + .chargeBlurb = "Charge to crack a small, brutal\nexplosion at the whip's tip.", + .flavor = "Die, monster! You don't belong\nin this world!", + .hit = HIT_LASH, + .follow = 0.4f, + .kick = -1.4f, + .normal = {.label = "lash", + .kind = ATTACK_WHIP, + .sound = SOUND_WHIP, + .duration = 0.22f, + .shake = 0.1f, + .spread = 7.5f}, + .charged = {.label = "lash + blast", + .kind = ATTACK_WHIP, + .sound = SOUND_WHIP_HEAVY, + .duration = 0.12f, + .shake = 0.3f, + .spread = 0.5f, + .hits = 0}, + .levels = + { + {.normal = {.damage = 18, .range = 125}, + .charged = {.area = 6, .damage = 42, .range = 125}, + .cooldown = 0.3f}, + {.normal = {.damage = 21, .range = 155}, + .charged = {.area = 7, .damage = 63, .range = 155}, + .cooldown = 0.3f}, + {.normal = {.damage = 24, .range = 185}, + .charged = {.area = 8, .damage = 84, .range = 185}, + .cooldown = 0.3f}, + }, + .bot = {.rangeMin = 90, .rangeMax = 160}, + }, + { + .name = "Grapple", + .blurb = "Fires a hook that latches on.\nMove to swing, Up and Down to\nreel, attack or charge to let go.", + .chargeBlurb = "Charge to plunge the hook in\ninstantly and get yanked to it.", + .flavor = "Get over here!", + .hit = HIT_PIERCE, + .normal = {.label = "hook", .kind = ATTACK_HOOK, .sound = SOUND_GRAPPLE, .shake = 0.2f, .spread = 2}, + .charged = {.label = "yanking hook", + .kind = ATTACK_HOOK, + .sound = SOUND_GRAPPLE_HEAVY, + .shake = 0.4f, + .spread = 1}, + .levels = + { + {.normal = {.damage = 16, .range = 155, .speed = 600}, + .charged = {.damage = 32, .range = 250, .speed = 400}, + .cooldown = 0.85f, + .reel = 120}, + {.normal = {.damage = 22, .range = 155, .speed = 700}, + .charged = {.damage = 45, .range = 300, .speed = 450}, + .cooldown = 0.85f, + .reel = 135}, + {.normal = {.damage = 28, .range = 155, .speed = 800}, + .charged = {.damage = 58, .range = 350, .speed = 500}, + .cooldown = 0.85f, + .reel = 150}, + }, + .bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD}, + }, + { + .name = "Boomerang", + .blurb = + "Throws a boomerang that returns,\nhitting twice. One more can be out\nper level. Charge with all out " + "to\ncall them back.", + .chargeBlurb = "Charge to throw a bigger one that\nhovers at its peak, hitting 4 times.", + .flavor = "An Australian favorite.", + .hit = HIT_BLUNT, + .normal = {.label = "boomerang", + .kind = ATTACK_BOOMERANG, + .sound = SOUND_BOOMERANG, + .duration = 0.35f, + .shake = 0.15f, + .spread = 8, + .hits = 2}, + .charged = {.label = "big boomerang", + .kind = ATTACK_BOOMERANG, + .sound = SOUND_BOOMERANG_HEAVY, + .duration = 0.4f, + .hold = 1.2f, + .scale = 2, + .shake = 0.3f, + .spread = 6, + .hits = 4}, + .levels = + { + {.normal = {.damage = 12, .range = 105, .speed = 300}, + .charged = {.damage = 20, .lunge = -150, .range = 170, .speed = 350}, + .cooldown = 0.2f, + .count = 1}, + {.normal = {.damage = 12, .range = 105, .speed = 300}, + .charged = {.damage = 20, .lunge = -150, .range = 170, .speed = 350}, + .cooldown = 0.2f, + .count = 2}, + {.normal = {.damage = 12, .range = 105, .speed = 300}, + .charged = {.damage = 20, .lunge = -150, .range = 170, .speed = 350}, + .cooldown = 0.2f, + .count = 3}, + }, + .bot = {.rangeMin = 40, .rangeMax = 120}, + }, + { + .name = "Bow", + .blurb = "Looses arrows that arc down with\ngravity. One more per level.", + .chargeBlurb = "The longer the draw, the faster\nand harder they fly.", + .flavor = "Erm, actually, bows require\nimmense upper body strengt-", + .hit = HIT_PIERCE, + .normal = {.label = "arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW, .shake = 0.05f, .spread = 6}, + .charged = + {.label = "drawn arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW_HEAVY, .shake = 0.3f, .spread = 3}, + .levels = + { + {.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, + .charged = {.damage = 22, .range = 500, .speed = 560, .volley = 1}, + .cooldown = 0.3f}, + {.normal = {.damage = 22, .range = 360, .speed = 360, .volley = 2}, + .charged = {.damage = 30, .range = 500, .speed = 670, .volley = 2}, + .cooldown = 0.3f}, + {.normal = {.damage = 22, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3}, + .charged = {.damage = 37, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3}, + .cooldown = 0.3f}, + }, + .bot = {.rangeMin = 100, .rangeMax = 260, .charge = CHARGE_ALWAYS, .aim = AIM_LOB}, + .isDrawn = true, + }, + { + .name = "Uzi", + .blurb = "Sprays tiny, fast bullets.", + .chargeBlurb = "Charge to spray a volley of even\nfaster bullets for double damage.", + .flavor = "Packin' heat!", + .hit = HIT_BULLET, + .flash = 0.7f, + .kick = -0.15f, + .normal = {.label = "bullet", .kind = ATTACK_BULLET, .sound = SOUND_UZI, .shake = 0.02f, .spread = 7}, + .charged = {.label = "bullet", + .kind = ATTACK_BULLET, + .sound = SOUND_UZI_HEAVY, + .shake = 0.25f, + .spread = 6, + .scatter = 1.1f}, + .levels = + { + {.normal = {.damage = 5, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = + {.damage = 6, .fan = 0.2f, .lunge = -60, .range = RANGE_INFINITE, .speed = 1200, .volley = 5}, + .cooldown = 0.12f}, + {.normal = {.damage = 7, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = + {.damage = 10, .fan = 0.2f, .lunge = -60, .range = RANGE_INFINITE, .speed = 1200, .volley = 7}, + .cooldown = 0.075f}, + {.normal = {.damage = 9, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = + {.damage = 14, .fan = 0.2f, .lunge = -60, .range = RANGE_INFINITE, .speed = 1200, .volley = 9}, + .cooldown = 0.05f}, + }, + .bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_NEVER, .aim = AIM_LEAD}, + .isEjecting = true, + }, + { + .name = "Shotgun", + .blurb = "Blasts a short cone of pellets\nthat kicks you back.", + .chargeBlurb = "Charge to fire a heavy slug that\npierces through targets with a\nbigger kick.", + .flavor = "This...is my BOOMSTICK!", + .hit = HIT_BULLET, + .flash = 1.5f, + .kick = -0.8f, + .normal = {.label = "pellet", + .kind = ATTACK_BULLET, + .sound = SOUND_SHOTGUN, + .shake = 0.3f, + .spread = 6, + .scatter = 2.3f}, + .charged = {.label = "slug", + .kind = ATTACK_BULLET, + .sound = SOUND_SHOTGUN_HEAVY, + .scale = 2.5f, + .shake = 0.5f, + .spread = 4, + .hits = 3}, + .levels = + { + {.normal = {.damage = 5, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5}, + .charged = {.damage = 28, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.0f}, + {.normal = {.damage = 6, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6}, + .charged = {.damage = 37, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.0f}, + {.normal = {.damage = 7, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7}, + .charged = {.damage = 46, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.0f}, + }, + .bot = {.rangeMax = 90, .aim = AIM_LEAD}, + .isEjecting = true, + .isReloading = true, + }, + { + .name = "Blaster", + .blurb = "Fires a pellet straight at\nthe cursor.", + .chargeBlurb = "Charge to fire an instant beam\nthat stops at walls.", + .flavor = "*pew pew pew*", + .hit = HIT_ENERGY, + .flash = 1, + .kick = -0.5f, + .normal = {.label = "bolt", .kind = ATTACK_PROJECTILE, .sound = SOUND_BLAST, .shake = 0.08f, .spread = 2}, + .charged = {.label = "beam", + .kind = ATTACK_BEAM, + .sound = SOUND_BEAM, + .duration = 0.3f, + .shake = 0.35f, + .spread = 0.5f}, + .levels = + { + {.normal = {.damage = 10, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 12, .lunge = -120, .range = RANGE_INFINITE}, + .cooldown = 0.2f}, + {.normal = {.damage = 13, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 24, .lunge = -120, .range = RANGE_INFINITE}, + .cooldown = 0.2f}, + {.normal = {.damage = 16, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 36, .lunge = -120, .range = RANGE_INFINITE}, + .cooldown = 0.2f}, + }, + .bot = {.rangeMin = 80, .rangeMax = 320, .aim = AIM_LEAD}, + }, + { + .name = "Flamethrower", + .blurb = "Sprays flames that drift up and\nlinger, burning whatever they touch.", + .chargeBlurb = "Charge to hurl a giant fireball\nthat explodes on impact or after\na short distance.", + .flavor = "Mmmph mmmph mmph!!", + .hit = HIT_FIRE, + .kick = -0.1f, + .normal = + {.label = "flame", .kind = ATTACK_FLAME, .sound = SOUND_FLAME, .shake = 0.01f, .spread = 16, .hits = 2}, + .charged = + {.label = "fireball", .kind = ATTACK_FIREBALL, .sound = SOUND_FIREBALL, .shake = 0.4f, .spread = 6}, + .levels = + { + {.normal = {.damage = 2, .duration = 1.5f, .range = 115, .speed = 500}, + .charged = {.area = 40, .damage = 30, .range = 160, .scale = 2, .speed = 300}, + .cooldown = 0.08f}, + {.normal = {.damage = 3, .duration = 2.25f, .range = 140, .speed = 650}, + .charged = {.area = 55, .damage = 42, .range = 180, .scale = 2.5f, .speed = 300}, + .cooldown = 0.08f}, + {.normal = {.damage = 4, .duration = 3, .range = 165, .speed = 800}, + .charged = {.area = 70, .damage = 54, .range = 200, .scale = 3, .speed = 300}, + .cooldown = 0.08f}, + }, + .bot = {.rangeMax = 110}, + }, + { + .name = "Rocket Launcher", + .blurb = "Fires a rocket that speeds up and\nexplodes on impact. Blasts push\nyou too.", + .chargeBlurb = "Charge to fire a big, slow missile\nwith a wider blast.", + .flavor = "RAWKET LAWNCHAIR", + .hit = HIT_EXPLOSIVE, + .kick = -0.6f, + .normal = {.label = "rocket", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET, .shake = 0.15f, .spread = 5}, + .charged = + {.label = "missile", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET_HEAVY, .shake = 0.3f, .spread = 4}, + .levels = + { + {.normal = {.area = 28, .damage = 14, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 46, .damage = 29, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + .cooldown = 0.9f}, + {.normal = {.area = 35, .damage = 18, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 58, .damage = 39, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + .cooldown = 0.9f}, + {.normal = {.area = 42, .damage = 22, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 69, .damage = 50, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + .cooldown = 0.9f}, + }, + .bot = {.rangeMin = 80, .rangeMax = 220, .aim = AIM_GROUND}, + }, + { + .name = "Mine", + .blurb = "Attack to set off every mine you've\nplaced. Blasts push you too.", + .chargeBlurb = "Charge to throw a floating mine\nout ahead of you. Levels allow\nmore out at once.", + .flavor = "Couldn't ya see the bloody bombs?", + .hit = HIT_EXPLOSIVE, + .follow = 0.5f, + .kick = -1, + .normal = {.label = "detonation", .kind = ATTACK_DETONATE, .sound = SOUND_DETONATE}, + .charged = {.label = "mine", + .kind = ATTACK_MINE, + .sound = SOUND_MINE, + .duration = 0.25f, + .shake = 0.1f, + .spread = 6, + .hits = 0}, + .levels = + { + {.normal = {.area = 56, .damage = 55, .range = 120}, + .charged = {.area = 56, .damage = 55, .range = 120}, + .cooldown = 0.3f, + .count = 2}, + {.normal = {.area = 66, .damage = 77, .range = 120}, + .charged = {.area = 66, .damage = 77, .range = 120}, + .cooldown = 0.3f, + .count = 4}, + {.normal = {.area = 76, .damage = 99, .range = 120}, + .charged = {.area = 76, .damage = 99, .range = 120}, + .cooldown = 0.3f, + .count = 6}, + }, + .bot = {.rangeMax = 120, .charge = CHARGE_ALWAYS}, + }, + { + .name = "Grimoire", + .blurb = + "Hold to keep conjuring wisps that\norbit you, up to a few at once;\nlet go to send them all at the\n" + "cursor. More wisps make more\nrings. Levels add wisps.", + .chargeBlurb = + "Charge to teleport to the cursor\n(up to 220u away, more with levels)\na moment after letting go, " + "with a\nsmall blast where you land.", + .flavor = "READ, NIGGA, READ!", + .hit = HIT_MAGIC, + .follow = 0.4f, + .kick = -0.6f, + .normal = {.label = "wisp", + .kind = ATTACK_WISP, + .sound = SOUND_CAST, + .duration = 0.35f, + .shake = 0.05f, + .spread = 4}, + .charged = {.label = "teleport", + .kind = ATTACK_TELEPORT, + .sound = SOUND_TELEPORT_WARP, + .duration = 0.175f, + .shake = 0.3f}, + .levels = + { + {.normal = {.damage = 24, .range = 320, .speed = 330, .volley = 1}, + .charged = {.area = 30, .damage = 20, .range = 220}, + .cooldown = 0.7f, + .limit = 3}, + {.normal = {.damage = 27, .range = 320, .speed = 350, .volley = 2}, + .charged = {.area = 40, .damage = 30, .range = 250}, + .cooldown = 0.7f, + .limit = 6}, + {.normal = {.damage = 30, .range = 320, .speed = 370, .volley = 3}, + .charged = {.area = 50, .damage = 40, .range = 280}, + .cooldown = 0.7f, + .limit = 9}, + }, + .bot = {.rangeMin = 60, .rangeMax = 260}, + }, +}; + +// One of a weapon's attacks as a fighter would fire it, for tooltips: its numbers with the fighter's trinkets' effects +// (zero where they don't apply), and whether the trinkets change each. Pieces is how many it fires (volley and +// Splitter copies), out how many can be out at once (0 for no limit), and range infinite for shots that never stop. +struct AttackSummary +{ + float damage = 0; + float every = 0; + float range = 0; + float area = 0; + float speed = 0; + float spread = 0; + float lunge = 0; + int pieces = 1; + int out = 0; + bool isDamaging = true; + bool isDamageChanged = false; + bool isEveryChanged = false; + bool isRangeChanged = false; + bool isAreaChanged = false; + bool isSpeedChanged = false; + bool isSpreadChanged = false; + bool isLungeChanged = false; + bool isPiecesChanged = false; +}; + +AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool isCharged); + +// A blast's attack: an explosion with no weapon of its own to describe it. +inline constexpr AttackDef EXPLOSION_DEF = {.kind = ATTACK_EXPLOSION, .duration = 0.35f, .shake = 0.5f}; + +// A live attack, carrying its level's numbers from when it was fired. Secondary attacks (off-hand and Doubler) +// draw gray. Bounces is how many ricochets it has +// left, and each ricochet it has made adds half the base damage again to its hits. +// Its hit shape is a capsule from position to tip with the given radius. +// A charged hook's speed is how fast it pulls the player in, rather than how fast it flies. +// Copies (extra Splitter pieces and Doubler repeats) don't count toward how many of a weapon are out. Weapon is what +// fired it, and key the owner's weapon slot it belongs to (see Fighter::weapon_key), or -1 if none. Travelled is +// how far a ranged shot has flown, which its damage falls off with. Bonus is the speed bonus it was fired with +// (explosions carry their source's whole strength), which its colour fills in with. It holds no pointers (what it is +// comes from its weapon, whether it's charged, and whether it's a blast), so its bytes mean the same on every platform +// and the game state can be sent whole to a player joining. +struct Attack +{ + AttackStats stats; + Vec2 origin; + Vec2 originPrevious; + Vec2 position; + Vec2 positionPrevious; + Vec2 tip; + Vec2 tipPrevious; + Vec2 velocity; + Vec2 orbit; + float aimAngle = 0; + float angle = 0; + float anglePrevious = 0; + float bonus = 1; + float duration = 0; + float hitCooldown = 0; + float length = 0; + float orbitAngle = 0; + float orbitRadius = 0; + float radius = 0; + float scale = 1; + float slot = 0; + float time = 0; + float timePrevious = 0; + float travelled = 0; + int bounces = 0; + int hitsLeft = 0; + int owner = 0; + int ricochets = 0; + int weapon = 0; + int key = -1; + bool isAttached = false; + bool isCharged = false; + bool isCopy = false; + bool isDead = false; + bool isExplosion = false; + bool isGathering = false; + bool isGentle = false; + bool isLeft = false; + bool isOffhand = false; + bool isPulling = false; + bool isRebounded = false; + bool isReturning = false; + bool isSecondary = false; + + const AttackDef* def() const + { + return isExplosion ? &EXPLOSION_DEF : isCharged ? &WEAPON_DEFS[weapon].charged : &WEAPON_DEFS[weapon].normal; + } +}; + +// A thick line segment used for hit tests. +struct Capsule +{ + Vec2 from; + Vec2 to; + float radius = 0; +}; + +// One hit's worth of damage and the feedback that goes with it. Source is where it came from, which a fighter gets +// knocked away from, and knock scales how hard. Targets are numbered fighters first, then dummies +// from MAX_FIGHTERS on. +struct Damage +{ + Vec2 point; + Vec2 source; + float hitstop = 0; + float knock = 1; + int amount = 0; + int owner = 0; + int target = 0; + SoundId sound; +}; + +// An explosion request: where, how big, how hard, whose it is (and which weapon, if any, for its hit sounds), its +// sound, whether it pushes fighters away, and whether its hits knock anyone back at all (gentle ones don't). +struct Blast +{ + Vec2 position; + float area = 0; + float bonus = 1; + float damage = 0; + int owner = 0; + SoundId sound; + int weapon = -1; + bool isGentle = false; + bool isPushing = false; + bool isSecondary = false; +}; + +// A mine is armed, and can be set off, a moment after it comes to rest. +inline bool is_mine_armed(Attack& attack) +{ + return attack.def()->kind == ATTACK_MINE && attack.time >= attack.duration + MINE_ARM_TIME; +} + +// One firing of a weapon slot: its charge, a damage multiplier, whether it's a Doubler repeat or from the off hand, +// and whether it's a secondary attack. +struct Shot +{ + float charge = 0; + float power = 1; + int weaponSlot = 0; + bool isEcho = false; + bool isOffhand = false; + bool isSecondary = false; +}; + +// A Doubler repeat waiting to fire: the charge it was fired with, its damage multiplier, and the weapon slot that +// fired it (it fires only if that slot's weapon is still in hand). +struct Echo +{ + float charge = 0; + float timer = 0; + float power = 0; + int weaponSlot = 0; +}; + +// Owns every attack in the world, spawns attacks from fighters and resolves +// their hits. Each attack records the fighter that owns it. +class Weapons +{ +public: + std::vector attacks; + float orbitClock = 0; + + void fire(Game& game, Fighter& fighter, float charge); + void update(Game& game); + void draw(Game& game); + void blast_draw(Game& game); + void collision_draw(Game& game); + void blend(float alpha); + void held_draw(Game& game, Fighter& fighter); + void offhand_draw(Game& game, Fighter& fighter); + void trajectory_draw(Game& game, Fighter& fighter); + float buff_get(Fighter& fighter); + float charge_fill_get(Fighter& fighter); + Vec2 jitter_get(float fill); + void blast_spawn(Game& game, Blast blast); + Attack* hook_get(Fighter& fighter); + void attacks_release(Fighter& fighter, AttackKind kind); + bool is_recalled(Game& game, Fighter& fighter); + bool is_busy(Fighter& fighter, int key); + bool is_spinning(Fighter& fighter); + void damage_deal(Game& game, Damage damage); + void contact_update(Game& game, Fighter& fighter); + void wisp_move(Game& game, Attack& attack); + void wisps_release(Fighter& fighter); + void teleport_move(Game& game, Attack& attack); + void magic_draw(Game& game); + std::vector solids_get(Fighter& fighter); + +private: + std::vector pending; + + bool is_spawned(Game& game, Fighter& fighter, Shot shot); + void attack_move(Game& game, Attack& attack); + void attack_remember(Attack& attack); + void attack_hit(Game& game, Attack& attack, int target, Vec2 point); + std::vector chain_get(Game& game, Fighter& fighter); + void tesla_update(Game& game); + void tesla_draw(Game& game); + Pose attack_pose(Game& game, Attack& attack); + Attack* tether_get(Fighter& fighter, int key, bool isOffhand); + void hand_draw(Game& game, Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw); + float kick_get(Fighter& fighter); + void hook_attach(Game& game, Attack& attack); + void piece_launch(Game& game, Attack& piece); + void attack_explode(Game& game, Attack& attack); + void flail_move(Game& game, Attack& attack); + void orbits_update(Game& game); + void attacks_cap(Game& game); +}; diff --git a/src/world.cpp b/src/world.cpp new file mode 100644 index 0000000..7e835b7 --- /dev/null +++ b/src/world.cpp @@ -0,0 +1,723 @@ +#include "world.h" +#include "game.h" +#include +#include +#include + +constexpr const char* ARENA[] = { + "####################################################", "###..............................................###", + "###..............................................###", "######........................................######", + "##................................................##", "##................................................##", + "##...................==========...................##", "##................................................##", + "##................................................##", "####.........3........................4.........####", + "####.......======..................======.......####", "##................................................##", + "##................................................##", "##................................................##", + "##======.............==========.............======##", "##................................................##", + "##................................................##", "##...........1........................2...........##", + "##.........======..................======.........##", "#######......................................#######", + "#######................######................#######", "#######WWW.........vvvv######VVVV.........www#######", + "####################################################", "####################################################", + "####################################################", "####################################################", +}; + +constexpr const char* CHUNK_START[COURSE_HEIGHT] = { + "..................", "..................", "..................", "..................", "..................", + "..................", "..................", "..................", "..................", "..................", + "..................", "..................", "..................", "..................", "..................", + "...1..2..3..4.....", "##################", "##################", "##################", "##################", + "##################", "##################", +}; + +constexpr const char* CHUNK_FINISH[COURSE_HEIGHT] = { + "......F........#", "......F........#", "......F........#", "......F........#", "......F........#", + "......F........#", "......F........#", "......F........#", "......F........#", "......F........#", + "......F........#", "......F........#", "......F........#", "......F........#", "......F........#", + "......F........#", "################", "################", "################", "################", + "################", "################", +}; + +constexpr const char* CHUNK_DOORS[COURSE_HEIGHT] = { + ".........#.......##.......", ".........#.......##.......", ".........#.......##.......", + ".........#.......##.......", ".........#.......##.......", ".........#.......##.......", + ".........#.......##.......", ".........#.......##.......", ".........#.......##.......", + ".........#.......##.......", ".........#.......##.......", ".........#.......##.......", + ".........#.......##.......", ".........B.......B........", ".........B.......B........", + ".........B.......B........", "##########################", "##########################", + "##########################", "##########################", "##########################", + "##########################", +}; + +constexpr const char* CHUNK_PITS[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "######...#####....#####...####", "######~~~#####~~~~#####~~~####", + "######~~~#####~~~~#####~~~####", "######~~~#####~~~~#####~~~####", "######~~~#####~~~~#####~~~####", + "######~~~#####~~~~#####~~~####", +}; + +constexpr const char* CHUNK_STAIRS[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", ".............####.............", ".............####.............", + ".........############.........", ".........############.........", ".....####################.....", + ".....####################.....", "##########################..##", "##########################~~##", + "##########################~~##", "##########################~~##", "##########################~~##", + "##########################~~##", +}; + +constexpr const char* CHUNK_HOPS[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", ".......===...===...===........", + "..............................", "####......................####", "####~~~~~~~~~~~~~~~~~~~~~~####", + "####~~~~~~~~~~~~~~~~~~~~~~####", "####~~~~~~~~~~~~~~~~~~~~~~####", "####~~~~~~~~~~~~~~~~~~~~~~####", + "####~~~~~~~~~~~~~~~~~~~~~~####", +}; + +constexpr const char* CHUNK_TUNNEL[COURSE_HEIGHT] = { + ".....####################.....", ".....####################.....", ".....####################.....", + ".....####################.....", ".....####################.....", ".....####################.....", + ".....####################.....", ".....####################.....", ".....####################.....", + ".....####################.....", ".....####################.....", ".....####################.....", + ".....####################.....", "...........B......B...........", "...........B......B...........", + "...........B......B...........", "##############################", "##############################", + "##############################", "##############################", "##############################", + "##############################", +}; + +constexpr const char* CHUNK_GATE[COURSE_HEIGHT] = { + "........................", "........................", "........................", "........................", + "........................", "........................", "........................", "..........##............", + "..........##............", "..........##............", "......===.##............", "..........##............", + "..........##............", "...===....B.............", "..........B.............", "..........B.............", + "########################", "########################", "########################", "########################", + "########################", "########################", +}; + +constexpr const char* CHUNK_ZIGZAG[COURSE_HEIGHT] = { + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "...........==.........==........", "................................", + ".....===........===.............", "###..........................###", "###~~~~~~~~~~~~~~~~~~~~~~~~~~###", + "###~~~~~~~~~~~~~~~~~~~~~~~~~~###", "###~~~~~~~~~~~~~~~~~~~~~~~~~~###", "###~~~~~~~~~~~~~~~~~~~~~~~~~~###", + "###~~~~~~~~~~~~~~~~~~~~~~~~~~###", +}; + +constexpr const char* CHUNK_PILLARS[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "...........##.................", "...........##...##............", "......##...##...##...##.......", + "......##...##...##...##.......", "###...##...##...##...##....###", "###~~~##~~~##~~~##~~~##~~~~###", + "###~~~##~~~##~~~##~~~##~~~~###", "###~~~##~~~##~~~##~~~##~~~~###", "###~~~##~~~##~~~##~~~##~~~~###", + "###~~~##~~~##~~~##~~~##~~~~###", +}; + +constexpr const char* CHUNK_MESA[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + ".............B................", ".............B................", ".............B................", + ".......##############.........", ".......##############.........", ".......##############.........", + "...wwww##############.........", "#####################....#####", "#####################~~~~#####", + "#####################~~~~#####", "#####################~~~~#####", "#####################~~~~#####", + "#####################~~~~#####", +}; + +constexpr const char* CHUNK_HILLS[COURSE_HEIGHT] = { + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", ".......######...................", + "...vvvv######UUUUUUUU..www......", "##########################....##", "##########################~~~~##", + "##########################~~~~##", "##########################~~~~##", "##########################~~~~##", + "##########################~~~~##", +}; + +constexpr const char* CHUNK_VALLEY[COURSE_HEIGHT] = { + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "..............................", "..............................", + "..............................", "####.................#########", "####.................#########", + "####WWW............xx#########", "###########~~~################", "###########~~~################", + "###########~~~################", +}; + +constexpr const char* CHUNK_STEEP[COURSE_HEIGHT] = { + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", "................................", "................................", + "................................", ".......######.......####........", ".......######.......####........", + "......y######Y....xx####VVVVVV..", "################################", "################################", + "################################", "################################", "################################", + "################################", +}; + +constexpr const char* CHUNK_SKATE[COURSE_HEIGHT] = { + "......................................", "......................................", + "......................................", "......................................", + "......................................", "......................................", + "......................................", "......................................", + "......................................", "......................................", + "......................................", "......................................", + ".........####.........................", ".........####.........................", + ".........####.........................", "..vvvvvvv####UUUUUUUUUUUUUUU..ww......", + "################################....##", "################################~~~~##", + "################################~~~~##", "################################~~~~##", + "################################~~~~##", "################################~~~~##", +}; + +constexpr const char* const* COURSE_CHUNKS[] = {CHUNK_DOORS, CHUNK_PITS, CHUNK_STAIRS, CHUNK_HOPS, CHUNK_TUNNEL, + CHUNK_GATE, CHUNK_ZIGZAG, CHUNK_PILLARS, CHUNK_MESA, CHUNK_HILLS, + CHUNK_VALLEY, CHUNK_STEEP, CHUNK_SKATE}; + +constexpr int COURSE_LENGTH = 8; +constexpr int CORNER_SIZE = 2; +constexpr int PLATFORM_HEIGHT = 8; +constexpr float BODY_EPSILON = 0.01f; +constexpr float BODY_STEP = TILE_SIZE / 2.0f; +constexpr const char* RISING_RAMPS = "uvwxy"; +constexpr const char* FALLING_RAMPS = "UVWXY"; +constexpr int STEEP_RAMP = 4; +constexpr float JUMP_SLACK = 60; +constexpr float RAMP_ANGLE_STEP = PI / 12; +constexpr float RAMP_EPSILON = 0.001f; +constexpr float RAMP_OUTLINE = 2; +constexpr float STICK_SLACK = 1; +constexpr float STICK_SLOPE = 1.75f; +constexpr float STEP_UP = BOX_WIDTH / 2 * STICK_SLOPE + STICK_SLACK; +constexpr Burst BREAK_BURST = {.gravity = 500, .life = 0.6f, .size = 4, .speed = 160, .count = 14}; +constexpr float BLOCK_HEALTH = 30; +constexpr float BLOCK_REGROW = 8; +constexpr float BLOCK_WARN = 1; +constexpr float DUMMY_HEIGHT = 28; +constexpr float DUMMY_WIDTH = 20; +constexpr float FLASH_DECAY = 8; +constexpr float WOBBLE_DAMPING = 5; +constexpr float WOBBLE_LIMIT = 0.7f; +constexpr float WOBBLE_STIFFNESS = 140; +constexpr Vec2 CORNER_DIRECTIONS[] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}}; + +static bool is_solid(Tile tile) +{ + return tile == TILE_SOLID || tile == TILE_BLOCK; +} + +void World::init(Game& game) +{ + sandbox_load(); +} + +// The arena with the practice dummy standing on the first surface below the middle of the map, and the player +// spawning right underneath it, on the next surface down. +void World::sandbox_load() +{ + arena_load(); + int column = width / 2; + int standRow = height / 2; + while (standRow < height && tile_get(column, standRow) == TILE_EMPTY) + standRow++; + + float standY = standRow * TILE_SIZE; + float centerX = width * TILE_SIZE / 2.0f; + dummies.push_back({.box = {centerX - DUMMY_WIDTH / 2, standY - DUMMY_HEIGHT, DUMMY_WIDTH, DUMMY_HEIGHT}}); + + int groundRow = standRow; + while (groundRow < height && tile_get(column, groundRow) != TILE_EMPTY) + groundRow++; + while (groundRow < height && tile_get(column, groundRow) == TILE_EMPTY) + groundRow++; + float groundY = groundRow * TILE_SIZE; + Ramp* ramp = ramp_find({centerX, groundY + TILE_SIZE / 2.0f}); + spawns[0] = {centerX, ramp ? ramp->y_at(centerX) : groundY}; +} + +void World::arena_load() +{ + std::vector rows(std::begin(ARENA), std::end(ARENA)); + map_load(rows); +} + +// A race course: the start, a run of chunks picked at random (never the same twice in a row), then the finish. +// RPG_FORCE_CHUNK fills it with one chunk, for trying a chunk out. +void World::course_build(Rng& rng) +{ + std::vector rows(CHUNK_START, CHUNK_START + COURSE_HEIGHT); + const char* forced = SDL_getenv("RPG_FORCE_CHUNK"); + int previous = -1; + for (int i = 0; i < COURSE_LENGTH; i++) + { + int pick = rng.random_get(0, std::size(COURSE_CHUNKS)); + while (pick == previous) + pick = rng.random_get(0, std::size(COURSE_CHUNKS)); + previous = pick; + if (forced) + pick = SDL_atoi(forced); + for (int y = 0; y < COURSE_HEIGHT; y++) + rows[y] += COURSE_CHUNKS[pick][y]; + } + for (int y = 0; y < COURSE_HEIGHT; y++) + rows[y] += CHUNK_FINISH[y]; + map_load(rows); +} + +// Fills the grid from rows of symbols: # solid, = platform, B breakable block, ~ pit, F finish line, 1 to 4 spawns. +// Every block gets a dummy to take its hits. +void World::map_load(std::vector& rows) +{ + height = rows.size(); + width = rows[0].size(); + tiles.assign(width * height, TILE_EMPTY); + dummies.clear(); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + char symbol = rows[y][x]; + Tile& tile = tiles[y * width + x]; + if (symbol == '#') + tile = TILE_SOLID; + else if (symbol == '=') + tile = TILE_PLATFORM; + else if (symbol == '~') + tile = TILE_PIT; + else if (symbol == 'F') + tile = TILE_FINISH; + else if (symbol >= '1' && symbol < '1' + SPAWN_COUNT) + spawns[symbol - '1'] = {(x + 0.5f) * TILE_SIZE, (y + 1.0f) * TILE_SIZE}; + if (symbol != 'B') + continue; + tile = TILE_BLOCK; + dummies.push_back({.box = {(float)x * TILE_SIZE, (float)y * TILE_SIZE, TILE_SIZE, TILE_SIZE}, + .health = BLOCK_HEALTH, + .tile = y * width + x}); + } + } + ramps_load(rows); +} + +// Ramps: a run of one ramp symbol along a row is a ramp standing on the ground under that row. u to y climb to the +// right at 15 to 75 degrees, and U to Y climb to the left. It rises a whole number of tiles at its angle, as many as +// fit across the run, and its tiles become slope tiles. +void World::ramps_load(std::vector& rows) +{ + ramps.clear(); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width;) + { + char symbol = rows[y][x]; + const char* rising = std::strchr(RISING_RAMPS, symbol); + const char* falling = std::strchr(FALLING_RAMPS, symbol); + if (symbol == 0 || (!rising && !falling)) + { + x++; + continue; + } + + int start = x; + while (x < width && rows[y][x] == symbol) + x++; + int kind = rising ? rising - RISING_RAMPS : falling - FALLING_RAMPS; + float steepness = math_tan((kind + 1) * RAMP_ANGLE_STEP); + int rise = std::max(1, (int)((x - start) * steepness + RAMP_EPSILON)); + Ramp ramp = { + .base = (y + 1.0f) * TILE_SIZE, + .left = (float)start * TILE_SIZE, + .right = (float)x * TILE_SIZE, + .slope = rising ? -steepness : steepness, + .isSteep = kind == STEEP_RAMP, + }; + ramp.anchor = rising ? ramp.left : ramp.right; + ramp.top = ramp.base - rise * TILE_SIZE; + ramps.push_back(ramp); + for (int row = y - rise + 1; row <= y; row++) + for (int column = start; column < x; column++) + tiles[row * width + column] = TILE_SLOPE; + } + } +} + +// Knocks a block out of the grid in a shower of debris in the breaker's colour, to grow back later. +void World::block_break(Game& game, Dummy& block, int breaker) +{ + tiles[block.tile] = TILE_EMPTY; + block.regrow = BLOCK_REGROW; + Vec2 center = {block.box.x + block.box.w / 2, block.box.y + block.box.h / 2}; + game.effects.burst(BREAK_BURST, center, 0, game.color_get(breaker)); + game.audio.play(SOUND_BREAK); +} + +// Dummies wobble back upright, and broken blocks grow back once their time is up and no fighter stands in them. +void World::update(Game& game) +{ + for (Dummy& dummy : dummies) + { + if (dummy.tile >= 0 && tiles[dummy.tile] == TILE_EMPTY) + { + dummy.regrow -= TICK; + bool isClear = true; + for (int i = 0; i < game.fighterCount; i++) + isClear &= !game.fighters[i].isAlive || !is_rect_overlapping(game.fighters[i].body.box, dummy.box); + if (dummy.regrow <= 0 && isClear) + { + tiles[dummy.tile] = TILE_BLOCK; + dummy.health = BLOCK_HEALTH; + } + } + dummy.wobbleSpeed += (-dummy.wobble * WOBBLE_STIFFNESS - dummy.wobbleSpeed * WOBBLE_DAMPING) * TICK; + dummy.wobble = std::clamp(dummy.wobble + dummy.wobbleSpeed * TICK, -WOBBLE_LIMIT, WOBBLE_LIMIT); + dummy.flash = std::max(0.0f, dummy.flash - FLASH_DECAY * TICK); + dummy.shockCooldown -= TICK; + } +} + +void World::draw(Game& game) +{ + Renderer& renderer = game.renderer; + Anm2& tileArt = game.sprites.tiles; + int startX = std::max(0, (int)std::floor(renderer.camera.x / TILE_SIZE)); + int startY = std::max(0, (int)std::floor(renderer.camera.y / TILE_SIZE)); + int endX = std::min(width - 1, (int)std::floor((renderer.camera.x + CANVAS_WIDTH) / TILE_SIZE)); + int endY = std::min(height - 1, (int)std::floor((renderer.camera.y + CANVAS_HEIGHT) / TILE_SIZE)); + + for (int y = startY; y <= endY; y++) + { + for (int x = startX; x <= endX; x++) + { + auto isOpen = [&](float dx, float dy) { + Tile neighbour = tile_get(x + dx, y + dy); + return neighbour != TILE_SOLID && neighbour != TILE_SLOPE; + }; + auto isEmpty = [&](int dx) { + return tile_get(x + dx, y) == TILE_EMPTY; + }; + Tile tile = tile_get(x, y); + Vec2 position = {(float)x * TILE_SIZE, (float)y * TILE_SIZE}; + + if (tile == TILE_SOLID) + { + int mask = isOpen(0, -1) | isOpen(1, 0) << 1 | isOpen(0, 1) << 2 | isOpen(-1, 0) << 3; + tileArt.draw(renderer, "Wall", mask, {.position = position}); + + for (Vec2 corner : CORNER_DIRECTIONS) + { + if (isOpen(corner.x, 0) || isOpen(0, corner.y) || !isOpen(corner.x, corner.y)) + continue; + float cornerX = corner.x < 0 ? position.x : position.x + TILE_SIZE - CORNER_SIZE; + float cornerY = corner.y < 0 ? position.y : position.y + TILE_SIZE - CORNER_SIZE; + renderer.rect_draw({cornerX, cornerY, CORNER_SIZE, CORNER_SIZE}, WHITE); + } + } + else if (tile == TILE_PLATFORM) + { + int mask = isEmpty(1) | isEmpty(-1) << 1; + tileArt.draw(renderer, "Platform", mask, {.position = position}); + } + else if (tile == TILE_PIT || tile == TILE_FINISH) + tileArt.draw(renderer, tile == TILE_PIT ? "Pit" : "Finish", 0, {.position = position}); + } + } + + // Ramps: an outline just inside the slope and its flat top, and a wall down the high end where it stands free. + for (Ramp& ramp : ramps) + { + if (ramp.right < renderer.camera.x || ramp.left > renderer.camera.x + CANVAS_WIDTH) + continue; + float edge = ramp.anchor + (ramp.top - ramp.base) / ramp.slope; + Vec2 low = {ramp.anchor, ramp.base}; + Vec2 high = {edge, ramp.top}; + Vec2 end = {ramp.slope < 0 ? ramp.right : ramp.left, ramp.top}; + Vec2 inset = vec2_normalize({ramp.slope, -1}) * (-RAMP_OUTLINE / 2); + renderer.line_draw(low + inset, high + inset, RAMP_OUTLINE, WHITE); + renderer.line_draw({high.x, high.y + RAMP_OUTLINE / 2}, {end.x, end.y + RAMP_OUTLINE / 2}, RAMP_OUTLINE, WHITE); + + float side = ramp.slope < 0 ? 1 : -1; + Vec2 beyond = {end.x + side * TILE_SIZE / 2, ramp.top + TILE_SIZE / 2}; + if (tile_at(beyond) != TILE_SOLID) + renderer.line_draw({end.x - side * RAMP_OUTLINE / 2, ramp.top}, + {end.x - side * RAMP_OUTLINE / 2, ramp.base}, RAMP_OUTLINE, WHITE); + } + + // Blocks crack as they lose health, and a broken one shows a faint outline just before it grows back. + for (Dummy& dummy : dummies) + { + if (dummy.tile < 0) + continue; + Vec2 position = {dummy.box.x, dummy.box.y}; + if (tiles[dummy.tile] == TILE_BLOCK) + { + int stage = std::min(BLOCK_STAGES - 1, (int)((1 - dummy.health / BLOCK_HEALTH) * BLOCK_STAGES)); + tileArt.draw(renderer, "Block", stage, {.position = position, .flash = dummy.flash}); + } + else if (dummy.regrow < BLOCK_WARN) + tileArt.draw(renderer, "Block", 0, + {.color = color_fade(GRAY, 1 - dummy.regrow / BLOCK_WARN), .position = position}); + } + + for (Dummy& dummy : dummies) + { + if (dummy.tile >= 0) + continue; + Vec2 base = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h}; + game.sprites.dummy.draw(renderer, "Idle", 0, {.position = base, .angle = dummy.wobble, .flash = dummy.flash}); + } +} + +// Moves a body by its velocity for one tick, in steps no longer than half a tile so fast bodies can't skip over +// or sink into walls. +void World::body_move(Body& body) +{ + float distance = std::max(std::abs(body.velocity.x), std::abs(body.velocity.y)) * TICK; + int steps = distance / BODY_STEP + 1; + bool isGrounded = false; + body.normal = {}; + + for (int i = 0; i < steps; i++) + { + body_step(body, TICK / steps); + isGrounded |= body.isGrounded; + } + body.isGrounded = isGrounded; +} + +// One step of movement, resolving x then y against the grid and any obstacles (which only stop bodies moving into +// them, never ones already inside). Platforms only catch bodies falling onto them. Then ramps: steep ones push bodies +// out sideways, walkable ones carry the feet, and grounded bodies stay stuck to the ground going over crests and down +// slopes unless they're jumping. A grounded body steps up a ledge as low as a walkable slope leaves under its edge, +// which carries it from a ramp's crest onto the ground beside it. +void World::body_step(Body& body, float time) +{ + Rect& box = body.box; + float previousBottom = box.y + box.h; + bool wasGrounded = body.isGrounded; + float slope = body.slope; + + box.x += body.velocity.x * time; + int top = std::floor(box.y / TILE_SIZE); + int bottom = std::floor((box.y + box.h - BODY_EPSILON) / TILE_SIZE); + bool isRight = body.velocity.x > 0; + int column = std::floor((isRight ? box.x + box.w - BODY_EPSILON : box.x) / TILE_SIZE); + for (int row = top; row <= bottom && body.velocity.x != 0; row++) + { + bool isStep = wasGrounded && box.y + box.h - row * TILE_SIZE <= STEP_UP && is_clear_above(column, row); + if (!is_solid(tile_get(column, row)) || isStep) + continue; + box.x = isRight ? column * TILE_SIZE - box.w : (column + 1) * TILE_SIZE; + body.velocity.x = 0; + } + for (Rect& solid : obstacles) + { + Rect before = {box.x - body.velocity.x * time, box.y, box.w, box.h}; + if (body.velocity.x == 0 || !is_rect_overlapping(box, solid) || is_rect_overlapping(before, solid)) + continue; + box.x = isRight ? solid.x - box.w : solid.x + solid.w; + body.velocity.x = 0; + } + + box.y += body.velocity.y * time; + body.isGrounded = false; + int left = std::floor(box.x / TILE_SIZE); + int right = std::floor((box.x + box.w - BODY_EPSILON) / TILE_SIZE); + bool isDown = body.velocity.y > 0; + int row = std::floor((isDown ? box.y + box.h - BODY_EPSILON : box.y) / TILE_SIZE); + for (int x = left; x <= right && body.velocity.y != 0; x++) + { + Tile tile = tile_get(x, row); + bool isLanding = isDown && !body.isDropping && previousBottom <= row * TILE_SIZE + BODY_EPSILON; + if (!is_solid(tile) && !(tile == TILE_PLATFORM && isLanding)) + continue; + box.y = isDown ? row * TILE_SIZE - box.h : (row + 1) * TILE_SIZE; + body.isGrounded = isDown; + body.velocity.y = 0; + } + for (Rect& solid : obstacles) + { + Rect before = {box.x, box.y - body.velocity.y * time, box.w, box.h}; + if (body.velocity.y == 0 || !is_rect_overlapping(box, solid) || is_rect_overlapping(before, solid)) + continue; + box.y = isDown ? solid.y - box.h : solid.y + solid.h; + body.isGrounded |= isDown; + body.velocity.y = 0; + } + + body.slope = 0; + ramp_push(body); + ramp_land(body, time); + if (!body.isGrounded && wasGrounded && body.velocity.y >= slope * body.velocity.x - JUMP_SLACK) + ground_stick(body, time, slope); +} + +// Whether the tile above a ledge is open, so a body could step up onto the ledge. +bool World::is_clear_above(int x, int y) +{ + return !is_solid(tile_get(x, y - 1)); +} + +// Steep ramps are walls: the bottom corner on the ramp's solid side, once inside it, goes back out sideways. +void World::ramp_push(Body& body) +{ + Rect& box = body.box; + for (Ramp& ramp : ramps) + { + bool isRising = ramp.slope < 0; + Vec2 corner = {isRising ? box.x + box.w : box.x, box.y + box.h}; + if (!ramp.isSteep || corner.x < ramp.left || corner.x >= ramp.right || corner.y <= ramp.y_at(corner.x)) + continue; + float edge = ramp.anchor + (std::min(corner.y, ramp.base) - ramp.base) / ramp.slope; + box.x = isRising ? edge - box.w : edge; + if (isRising ? body.velocity.x > 0 : body.velocity.x < 0) + body.velocity.x = 0; + body.normal = ramp.normal_at(edge); + } +} + +// Walkable ramps hold up the middle of the feet. Feet that sank in this step come back up onto the line and the body +// moves along it. Feet buried deeper came in through the ramp's high end, and go back out of it. +void World::ramp_land(Body& body, float time) +{ + Rect& box = body.box; + Vec2 feet = {box.x + box.w / 2, box.y + box.h}; + Ramp* ramp = ramp_find(feet); + if (!ramp || ramp->isSteep) + return; + float surface = ramp->y_at(feet.x); + float depth = feet.y - surface; + if (depth < 0) + return; + + float reach = (std::abs(body.velocity.x * ramp->slope) + std::max(0.0f, body.velocity.y)) * time + STICK_SLACK; + float end = ramp->slope < 0 ? ramp->right : ramp->left; + float out = std::abs(end - (ramp->slope < 0 ? box.x : box.x + box.w)); + body.normal = ramp->normal_at(feet.x); + if (depth > reach && out < depth) + { + box.x = ramp->slope < 0 ? end : end - box.w; + body.velocity.x = 0; + return; + } + box.y = surface - box.h; + body.isGrounded = true; + body.slope = ramp->slope_at(feet.x); + body.velocity.y = body.slope * body.velocity.x; +} + +// A body that was on the ground and isn't jumping drops onto ground just below its feet, within what its speed could +// carry it down the steepest walkable slope this step. +void World::ground_stick(Body& body, float time, float slope) +{ + Rect& box = body.box; + Vec2 feet = {box.x + box.w / 2, box.y + box.h}; + float reach = std::abs(body.velocity.x) * time * STICK_SLOPE + STICK_SLACK; + Ramp* ramp = ramp_find({feet.x, feet.y + reach}); + if (ramp && !ramp->isSteep && ramp->y_at(feet.x) - feet.y <= reach) + { + box.y = ramp->y_at(feet.x) - box.h; + body.isGrounded = true; + body.slope = ramp->slope_at(feet.x); + body.velocity.y = body.slope * body.velocity.x; + return; + } + + int row = std::floor((feet.y + reach) / TILE_SIZE); + if (row * TILE_SIZE <= feet.y) + return; + int left = std::floor(box.x / TILE_SIZE); + int right = std::floor((box.x + box.w - BODY_EPSILON) / TILE_SIZE); + for (int x = left; x <= right; x++) + { + Tile tile = tile_get(x, row); + if (!is_solid(tile) && !(tile == TILE_PLATFORM && !body.isDropping)) + continue; + box.y = row * TILE_SIZE - box.h; + body.isGrounded = true; + body.velocity.y = 0; + return; + } +} + +// The ramp whose surface lies nearest the point, among those spanning its x. +Ramp* World::ramp_find(Vec2 point) +{ + Ramp* best = nullptr; + for (Ramp& ramp : ramps) + { + bool isSpanning = point.x >= ramp.left && point.x < ramp.right; + if (isSpanning && (!best || std::abs(ramp.y_at(point.x) - point.y) < std::abs(best->y_at(point.x) - point.y))) + best = &ramp; + } + return best; +} + +// Which way a ramp's surface faces where a point meets it, or zero off ramps. +Vec2 World::normal_get(Vec2 point) +{ + Ramp* ramp = tile_at(point) == TILE_SLOPE ? ramp_find(point) : nullptr; + return ramp ? ramp->normal_at(point.x) : Vec2{}; +} + +Tile World::tile_get(int x, int y) +{ + if (x < 0 || y < 0 || x >= width || y >= height) + return TILE_SOLID; + return tiles[y * width + x]; +} + +Tile World::tile_at(Vec2 point) +{ + return tile_get(std::floor(point.x / TILE_SIZE), std::floor(point.y / TILE_SIZE)); +} + +// Solid tiles and blocks, and slope tiles below their ramp's line. +bool World::is_solid_at(Vec2 point) +{ + Tile tile = tile_at(point); + if (tile == TILE_SLOPE) + { + Ramp* ramp = ramp_find(point); + return ramp && point.y > ramp->y_at(point.x); + } + return is_solid(tile); +} + +// What attacks collide with and hooks grab: solid tiles anywhere, and platforms within their drawn strip. Bodies +// still pass up through platforms and can drop through them. +bool World::is_blocking_at(Vec2 point) +{ + Tile tile = tile_at(point); + return is_solid(tile) || + (tile == TILE_PLATFORM && point.y - std::floor(point.y / TILE_SIZE) * TILE_SIZE < PLATFORM_HEIGHT); +} + +// Whether any part of the box overlaps a tile of the given kind. +bool World::is_touching(Rect box, Tile tile) +{ + int left = std::floor(box.x / TILE_SIZE); + int right = std::floor((box.x + box.w - BODY_EPSILON) / TILE_SIZE); + int top = std::floor(box.y / TILE_SIZE); + int bottom = std::floor((box.y + box.h - BODY_EPSILON) / TILE_SIZE); + for (int y = top; y <= bottom; y++) + for (int x = left; x <= right; x++) + if (tile_get(x, y) == tile) + return true; + return false; +} + +Vec2 World::size_get() +{ + return {(float)width * TILE_SIZE, (float)height * TILE_SIZE}; +} diff --git a/src/world.h b/src/world.h new file mode 100644 index 0000000..e1683cb --- /dev/null +++ b/src/world.h @@ -0,0 +1,121 @@ +#pragma once + +#include "common.h" +#include +#include +#include + +struct Game; + +enum Tile : unsigned char +{ + TILE_EMPTY, + TILE_SOLID, + TILE_PLATFORM, + TILE_BLOCK, + TILE_PIT, + TILE_FINISH, + TILE_SLOPE +}; + +// An axis-aligned box that collides with the tile grid and ramps. Slope is the rise per pixel of the ground it stands +// on, and normal is the surface of the last ramp it touched while moving (zero when it touched none). +struct Body +{ + Rect box; + Vec2 normal; + Vec2 velocity; + float slope = 0; + bool isDropping = false; + bool isGrounded = false; +}; + +// A straight slope with solid ground under it. It climbs from its base at the anchor to its top at a fixed angle, and +// runs flat for the rest of its last tile. Slope is the rise per pixel across (negative when it climbs to the right). +// Steep ramps are too steep to stand on. +struct Ramp +{ + float anchor = 0; + float base = 0; + float left = 0; + float right = 0; + float slope = 0; + float top = 0; + bool isSteep = false; + + float y_at(float x) + { + return std::clamp(base + slope * (x - anchor), top, base); + } + + float slope_at(float x) + { + float y = base + slope * (x - anchor); + return y > top && y < base ? slope : 0; + } + + Vec2 normal_at(float x) + { + return vec2_normalize({slope_at(x), -1}); + } +}; + +// Something to hit that isn't a fighter: a practice dummy, or a breakable block standing in a tile of the grid. A +// block breaks once its health runs out and grows back when regrow runs out, if nobody is in the way. +struct Dummy +{ + Rect box; + float flash = 0; + float health = 0; + float regrow = 0; + float shockCooldown = 0; + float wobble = 0; + float wobbleSpeed = 0; + int tile = -1; +}; + +constexpr int BLOCK_STAGES = 3; +constexpr int COURSE_HEIGHT = 22; +constexpr int SPAWN_COUNT = 4; + +// Tile grid of any size, its collision, and the things standing in it. Spawns are where fighters stand at the start +// of a round, marked 1 to 4 in the map. Obstacles are extra solid boxes for the body moving right now, like a +// fighter's own solid attacks. The map is the deathmatch arena or a race course stitched from chunks. Ramp tiles are +// solid below their ramp's line, and bodies meet ramps by their own rules rather than the grid's. +class World +{ +public: + std::vector dummies; + std::vector obstacles; + std::vector ramps; + std::vector tiles; + Vec2 spawns[SPAWN_COUNT]; + int height = 0; + int width = 0; + + void init(Game& game); + void update(Game& game); + void draw(Game& game); + void arena_load(); + void sandbox_load(); + void course_build(Rng& rng); + void block_break(Game& game, Dummy& block, int breaker); + void body_move(Body& body); + Tile tile_get(int x, int y); + Tile tile_at(Vec2 point); + bool is_solid_at(Vec2 point); + bool is_blocking_at(Vec2 point); + bool is_touching(Rect box, Tile tile); + Ramp* ramp_find(Vec2 point); + Vec2 normal_get(Vec2 point); + Vec2 size_get(); + +private: + void map_load(std::vector& rows); + void ramps_load(std::vector& rows); + void body_step(Body& body, float time); + void ramp_push(Body& body); + bool is_clear_above(int x, int y); + void ramp_land(Body& body, float time); + void ground_stick(Body& body, float time, float slope); +}; diff --git a/tools/assets_generate.py b/tools/assets_generate.py new file mode 100644 index 0000000..d6df0c2 --- /dev/null +++ b/tools/assets_generate.py @@ -0,0 +1,777 @@ +# Generates the prototype's synthesized sound effects into build/sfx-sets/synth (for the sound review), made with numpy +# and encoded to ogg through ffmpeg. The game's own sounds (resources/sfx) are built by tools/sfx_build.py, whose +# loudness targets (tools/sfx_loudness.json) were measured from these. The art is drawn by hand in resources/gfx. + +import math +import pathlib +import subprocess +import tempfile +import wave + +import numpy as np + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SFX = ROOT / "build" / "sfx-sets" / "synth" + +RATE = 44100 + + +def time_axis(seconds): + return np.arange(int(RATE * seconds)) / RATE + + +def envelope(t, attack, power): + length = t[-1] if len(t) else 1 + rise = np.clip(t / max(attack, 1e-4), 0, 1) + fall = (1 - t / length) ** power + return rise * fall + + +def square(phase): + return np.sign(np.sin(phase)) + + +def sweep_phase(t, start, end): + frequency = start + (end - start) * (t / t[-1]) + return 2 * math.pi * np.cumsum(frequency) / RATE + + +def noise(t, seed): + return np.random.default_rng(seed).uniform(-1, 1, len(t)) + + +def lowpass(signal, amount): + out = np.zeros_like(signal) + value = 0.0 + for i, sample in enumerate(signal): + value += (sample - value) * amount + out[i] = value + return out + + +def lowpass_sweep(signal, start, end): + out = np.zeros_like(signal) + value = 0.0 + amounts = np.linspace(start, end, len(signal)) + for i, sample in enumerate(signal): + value += (sample - value) * amounts[i] + out[i] = value + return out + + +def sound_jump(): + t = time_axis(0.14) + return 0.35 * square(sweep_phase(t, 260, 620)) * envelope(t, 0.005, 1.5) + + +def sound_land(): + t = time_axis(0.1) + return 0.9 * lowpass(noise(t, 1), 0.08) * envelope(t, 0.002, 2) + 0.4 * square(sweep_phase(t, 110, 50)) * envelope(t, 0.002, 3) + + +def sound_step(): + t = time_axis(0.04) + return 0.6 * lowpass(noise(t, 2), 0.2) * envelope(t, 0.001, 3) + + +def sound_whoosh(seconds, start, end, seed): + t = time_axis(seconds) + return 1.2 * lowpass_sweep(noise(t, seed), start, end) * np.sin(math.pi * t / t[-1]) + + +def sound_poke(): + t = time_axis(0.12) + return 0.8 * lowpass_sweep(noise(t, 5), 0.6, 0.05) * envelope(t, 0.002, 2) + 0.2 * square(sweep_phase(t, 700, 300)) * envelope(t, 0.002, 4) + + +def sound_blast(): + t = time_axis(0.12) + return 0.35 * square(sweep_phase(t, 900, 220)) * envelope(t, 0.002, 1.5) + + +def sound_beam(): + t = time_axis(0.45) + wobble = 2 * math.pi * np.cumsum(1200 + 300 * np.sin(2 * math.pi * 30 * t)) / RATE + return 0.3 * (square(wobble) * 0.6 + noise(t, 6) * 0.4) * envelope(t, 0.005, 1.2) + + +def sound_boomerang(): + t = time_axis(0.3) + flutter = 0.5 + 0.5 * np.sin(2 * math.pi * 24 * t) + return 1.0 * lowpass(noise(t, 7), 0.15) * flutter * np.sin(math.pi * t / t[-1]) + + +def sound_slam(): + t = time_axis(0.6) + boom = np.sin(sweep_phase(t, 120, 30)) * envelope(t, 0.002, 2) + rumble = lowpass(noise(t, 9), 0.05) * envelope(t, 0.002, 1.5) + return 0.8 * boom + 1.5 * rumble + + +def sound_charge(): + t = time_axis(0.8) + tone = np.sin(sweep_phase(t, 220, 440)) + 0.3 * np.sin(sweep_phase(t, 440, 880)) + shimmer = 0.75 + 0.25 * np.sin(2 * math.pi * 12 * t) + return 0.12 * tone * shimmer * np.clip(t / 0.15, 0, 1) + + +def sound_ready(): + t = time_axis(0.5) + bell = np.sin(2 * math.pi * 660 * t) + 0.6 * np.sin(2 * math.pi * 990 * t) + 0.2 * np.sin(2 * math.pi * 1320 * t) + return 0.16 * bell * envelope(t, 0.005, 3) + + +def sound_arpeggio(frequencies): + t = time_axis(0.36) + step = len(t) // len(frequencies) + frequency = np.array([frequencies[min(i // step, len(frequencies) - 1)] for i in range(len(t))]) + phase = 2 * math.pi * np.cumsum(frequency) / RATE + return 0.3 * square(phase) * envelope(t, 0.002, 1.2) + + +def sound_twang(): + t = time_axis(0.25) + string = np.sin(sweep_phase(t, 260, 200)) + 0.5 * np.sin(sweep_phase(t, 520, 400)) + click = lowpass(noise(t, 11), 0.4) * envelope(t, 0.001, 12) + return 0.4 * string * envelope(t, 0.002, 3) + 0.5 * click + + +def sound_rocket(): + t = time_axis(0.4) + thump = np.sin(sweep_phase(t, 90, 40)) * envelope(t, 0.002, 6) + hiss = lowpass_sweep(noise(t, 12), 0.05, 0.5) * envelope(t, 0.03, 1.5) + return 0.6 * thump + 0.8 * hiss + + +def sound_grapple(): + t = time_axis(0.16) + zip_tone = square(sweep_phase(t, 400, 1300)) * envelope(t, 0.003, 1.5) + hiss = lowpass_sweep(noise(t, 13), 0.1, 0.7) * envelope(t, 0.003, 1.5) + return 0.2 * zip_tone + 0.5 * hiss + + +def sound_latch(): + t = time_axis(0.18) + ring = np.sin(2 * math.pi * 1800 * t) + 0.7 * np.sin(2 * math.pi * 2650 * t) + click = lowpass(noise(t, 14), 0.6) * envelope(t, 0.001, 10) + return 0.25 * ring * envelope(t, 0.001, 4) + 0.6 * click + + +def sound_explosion(): + t = time_axis(0.9) + rumble = lowpass(noise(t, 15), 0.03) * envelope(t, 0.005, 1.3) + crackle = (np.random.default_rng(16).random(len(t)) > 0.995) * envelope(t, 0.01, 2) + boom = np.sin(sweep_phase(t, 70, 25)) * envelope(t, 0.002, 2.5) + return 2.2 * rumble + 0.6 * crackle + 0.6 * boom + + +def sound_flail(): + t = time_axis(0.3) + rattle = 0.5 + 0.5 * np.sign(np.sin(2 * math.pi * 38 * t)) + whoosh = lowpass_sweep(noise(t, 17), 0.05, 0.35) * np.sin(math.pi * t / t[-1]) + return 1.1 * whoosh * (0.4 + 0.6 * rattle) + + +def sound_flail_land(): + t = time_axis(0.22) + thud = np.sin(sweep_phase(t, 90, 45)) * envelope(t, 0.001, 4) + clank = np.sin(2 * math.pi * 900 * t) * envelope(t, 0.001, 8) + return 0.8 * thud + 0.25 * clank + + +def sound_dash(): + t = time_axis(0.14) + air = lowpass_sweep(noise(t, 18), 0.6, 0.15) * envelope(t, 0.002, 1.5) + chirp = np.sin(sweep_phase(t, 600, 900)) * envelope(t, 0.002, 3) + return 0.8 * air + 0.15 * chirp + + +def sound_climb(): + t = time_axis(0.12) + scrape = lowpass(noise(t, 19), 0.35) * envelope(t, 0.001, 2) + blip = square(sweep_phase(t, 300, 520)) * envelope(t, 0.001, 2.5) + return 0.6 * scrape + 0.25 * blip + + +def sound_echo(): + t = time_axis(0.28) + shimmer = np.sin(2 * math.pi * 1400 * t) * (0.6 + 0.4 * np.sin(2 * math.pi * 22 * t)) + return 0.25 * shimmer * envelope(t, 0.005, 2) + + +def place(signal, part, seconds): + start = int(seconds * RATE) + signal[start : start + len(part)] += part[: max(0, len(signal) - start)] + + +def part_click(seed): + t = time_axis(0.012) + return lowpass(noise(t, seed), 0.8) * envelope(t, 0.0005, 6) + + +def part_slide(rising, seed): + t = time_axis(0.07) + start, end = (0.08, 0.5) if rising else (0.5, 0.08) + return 0.6 * lowpass_sweep(noise(t, seed), start, end) * np.sin(math.pi * t / t[-1]) + + +def part_clack(): + t = time_axis(0.12) + thump = np.sin(sweep_phase(t, 150, 70)) * envelope(t, 0.001, 5) + metal = (np.sin(2 * math.pi * 1700 * t) + 0.6 * np.sin(2 * math.pi * 2450 * t)) * envelope(t, 0.001, 9) + return 0.7 * thump + 0.25 * metal + + +def sound_equip(): + signal = np.zeros(int(RATE * 0.26)) + place(signal, part_click(22), 0) + place(signal, part_slide(True, 23), 0.025) + place(signal, part_clack(), 0.11) + place(signal, 0.7 * part_click(24), 0.11) + return signal + + +def sound_unequip(): + signal = np.zeros(int(RATE * 0.26)) + place(signal, 0.6 * part_clack(), 0) + place(signal, part_slide(False, 25), 0.06) + place(signal, 0.8 * part_click(26), 0.15) + return signal + + +def sound_swap(): + t = time_axis(0.09) + ticks = np.zeros_like(t) + ticks[: len(t) // 6] = 1 + ticks[len(t) // 2 : len(t) // 2 + len(t) // 6] = 1 + return 0.5 * lowpass(noise(t, 20), 0.5) * ticks + + +def sound_ricochet(): + t = time_axis(0.2) + ping = np.sin(sweep_phase(t, 2600, 1500)) * envelope(t, 0.001, 3) + click = lowpass(noise(t, 27), 0.7) * envelope(t, 0.0005, 14) + return 0.3 * ping + 0.5 * click + + +def sound_pound(): + t = time_axis(0.55) + thud = np.sin(sweep_phase(t, 60, 30)) * envelope(t, 0.002, 2.5) + crunch = lowpass(noise(t, 28), 0.2) * envelope(t, 0.002, 5) + clang = np.sin(2 * math.pi * 420 * t) * envelope(t, 0.001, 7) + return 1.0 * thud + 0.9 * crunch + 0.2 * clang + + +def sound_zap(): + t = time_axis(0.12) + buzz = square(2 * math.pi * 90 * t) * (np.random.default_rng(29).random(len(t)) > 0.4) + crackle = lowpass(noise(t, 30), 0.9) * envelope(t, 0.001, 3) + return (0.25 * buzz + 0.4 * crackle) * envelope(t, 0.002, 2) + + +def sound_uzi(): + t = time_axis(0.06) + pop = square(sweep_phase(t, 1400, 500)) * envelope(t, 0.0005, 3) + crack = lowpass(noise(t, 31), 0.8) * envelope(t, 0.0005, 6) + return 0.25 * pop + 0.45 * crack + + +def sound_mine(): + t = time_axis(0.25) + clunk = np.sin(sweep_phase(t, 220, 120)) * envelope(t, 0.001, 8) + lowpass(noise(t, 32), 0.5) * envelope(t, 0.001, 14) + beep = square(2 * math.pi * 1600 * t) * ((t > 0.12) & (t < 0.17)) + return 0.6 * clunk + 0.15 * beep + + +def sound_detonate(): + t = time_axis(0.12) + gate = (t < 0.04) | ((t > 0.07) & (t < 0.11)) + return 0.2 * square(2 * math.pi * 2200 * t) * gate + + +def sound_flame(): + t = time_axis(0.12) + roar = lowpass_sweep(noise(t, 33), 0.08, 0.2) * np.sin(math.pi * t / t[-1]) + crackle = (np.random.default_rng(34).random(len(t)) > 0.985) * envelope(t, 0.002, 2) + return 0.6 * roar + 0.15 * crackle + + +def sound_fireball(): + t = time_axis(0.5) + whoomp = np.sin(sweep_phase(t, 140, 60)) * envelope(t, 0.01, 3) + roar = lowpass_sweep(noise(t, 35), 0.3, 0.04) * envelope(t, 0.02, 2) + return 0.6 * whoomp + 1.2 * roar + + +def highpass(signal, amount): + return signal - lowpass(signal, amount) + + +def sound_whip(): + t = time_axis(0.2) + swish = lowpass_sweep(noise(t, 36), 0.02, 0.3) * np.clip(t / 0.04, 0, 1) * (t < 0.04) + crack = highpass(noise(t, 37), 0.3) * envelope(np.maximum(t - 0.04, 0), 0.0002, 40) * (t >= 0.04) + snap = square(sweep_phase(t, 3000, 500)) * envelope(np.maximum(t - 0.04, 0), 0.0003, 30) * (t >= 0.04) + thump = np.sin(sweep_phase(t, 180, 60)) * envelope(np.maximum(t - 0.04, 0), 0.001, 12) * (t >= 0.04) + return 0.95 * np.tanh(3 * (0.3 * swish + 1.4 * crack + 0.35 * snap + 0.9 * thump)) + + +def sound_whip_blast(): + t = time_axis(0.5) + crack = highpass(noise(t, 38), 0.3) * envelope(t, 0.0002, 25) + boom = np.sin(sweep_phase(t, 130, 35)) * envelope(t, 0.001, 4) + rumble = lowpass(noise(t, 39), 0.06) * envelope(t, 0.002, 5) + return 0.95 * np.tanh(4 * (1.2 * crack + 1.0 * boom + 1.6 * rumble)) + + +def sound_clack(t, start, pitch, seed): + local = np.maximum(t - start, 0) + gate = (t >= start) & (local < 0.05) + rattle = highpass(noise(t, seed), 0.4) * (1 - local / 0.05) ** 4 + ring = np.sin(2 * math.pi * pitch * local) * (1 - local / 0.05) ** 6 + thunk = np.sin(2 * math.pi * 220 * local) * (1 - local / 0.05) ** 3 + return gate * (0.9 * rattle + 0.35 * ring + 0.5 * thunk) + + +def sound_shotgun(): + t = time_axis(0.7) + boom_t = t[t < 0.45] + blast = highpass(noise(boom_t, 40), 0.15) * envelope(boom_t, 0.0002, 14) + body = lowpass(noise(boom_t, 41), 0.12) * envelope(boom_t, 0.001, 5) + boom = np.sin(sweep_phase(boom_t, 110, 40)) * envelope(boom_t, 0.001, 6) + shot = np.zeros_like(t) + shot[: len(boom_t)] = 0.95 * np.tanh(4 * (1.0 * blast + 1.4 * body + 1.0 * boom)) + pump = sound_clack(t, 0.42, 1500, 42) + sound_clack(t, 0.56, 2100, 43) + return shot + 0.6 * pump + + +def sound_death(): + t = time_axis(0.7) + boom = np.sin(sweep_phase(t, 220, 40)) * envelope(t, 0.001, 3) + crunch = lowpass_sweep(noise(t, 44), 0.5, 0.03) * envelope(t, 0.001, 2.5) + chime = np.sin(sweep_phase(t, 880, 220)) * envelope(t, 0.002, 4) + return 0.95 * np.tanh(3 * (0.9 * boom + 1.2 * crunch + 0.3 * chime)) + + +def sound_bounce(): + t = time_axis(0.22) + thud = np.sin(sweep_phase(t, 170, 60)) * envelope(t, 0.001, 5) + smack = lowpass(noise(t, 45), 0.4) * envelope(t, 0.0005, 14) + return 0.95 * np.tanh(2.5 * (1.0 * thud + 0.8 * smack)) + + +def sound_detonate_empty(): + t = time_axis(0.05) + click = highpass(noise(t, 46), 0.5) * envelope(t, 0.0003, 20) + tick = np.sin(2 * math.pi * 1400 * t) * envelope(t, 0.0005, 12) + return 0.12 * click + 0.06 * tick + + +def sound_slash_heavy(): + t = time_axis(0.4) + whoosh = lowpass_sweep(noise(t, 50), 0.03, 0.25) * np.sin(math.pi * t / t[-1]) + shing = (np.sin(2 * math.pi * 900 * t) + 0.5 * np.sin(2 * math.pi * 1350 * t)) * envelope(t, 0.01, 6) + thump = np.sin(sweep_phase(t, 130, 50)) * envelope(t, 0.002, 6) + return 0.95 * np.tanh(1.6 * (1.3 * whoosh + 0.15 * shing + 0.8 * thump)) + + +def sound_swing_heavy(): + t = time_axis(0.55) + whoosh = lowpass_sweep(noise(t, 51), 0.01, 0.12) * np.sin(math.pi * t / t[-1]) ** 0.7 + growl = np.sin(sweep_phase(t, 55, 38)) * (0.5 + 0.5 * np.sin(2 * math.pi * 9 * t)) * envelope(t, 0.05, 2) + return 0.95 * np.tanh(2 * (1.6 * whoosh + 0.7 * growl)) + + +def sound_flail_heavy(): + t = time_axis(0.45) + rattle = 0.5 + 0.5 * np.sign(np.sin(2 * math.pi * 16 * t)) + swirl = lowpass_sweep(noise(t, 52), 0.02, 0.15) * np.sin(math.pi * t / t[-1]) + clunk = np.sin(sweep_phase(t, 160, 70)) * envelope(t, 0.001, 10) + return 0.95 * np.tanh(1.8 * (1.3 * swirl * (0.4 + 0.6 * rattle) + 0.7 * clunk)) + + +def sound_poke_heavy(): + t = time_axis(0.35) + rush = lowpass_sweep(noise(t, 53), 0.25, 0.02) * envelope(t, 0.01, 1.5) + boom = np.sin(sweep_phase(t, 120, 45)) * envelope(t, 0.002, 4) + edge = square(sweep_phase(t, 420, 160)) * envelope(t, 0.002, 6) + return 0.95 * np.tanh(1.8 * (1.2 * rush + 0.9 * boom + 0.12 * edge)) + + +def sound_whip_heavy(): + t = time_axis(0.35) + hit = 0.1 + swish = lowpass_sweep(noise(t, 54), 0.01, 0.25) * np.clip(t / hit, 0, 1) * (t < hit) + after = np.maximum(t - hit, 0) + crack = highpass(noise(t, 55), 0.25) * envelope(after, 0.0002, 30) * (t >= hit) + boom = np.sin(sweep_phase(t, 110, 35)) * envelope(after, 0.001, 5) * (t >= hit) + return 0.95 * np.tanh(3 * (0.5 * swish + 1.1 * crack + 1.2 * boom)) + + +def sound_grapple_heavy(): + t = time_axis(0.25) + zing = square(sweep_phase(t, 180, 600)) * envelope(t, 0.003, 2) + cable = lowpass_sweep(noise(t, 56), 0.05, 0.4) * envelope(t, 0.003, 2) + thunk = np.sin(sweep_phase(t, 140, 55)) * envelope(np.maximum(t - 0.12, 0), 0.001, 6) * (t >= 0.12) + return 0.95 * np.tanh(1.8 * (0.25 * zing + 0.6 * cable + 1.0 * thunk)) + + +def sound_boomerang_heavy(): + t = time_axis(0.5) + flutter = 0.5 + 0.5 * np.sin(2 * math.pi * 13 * t) + whoosh = lowpass(noise(t, 57), 0.06) * flutter * np.sin(math.pi * t / t[-1]) + hum = np.sin(2 * math.pi * 70 * t) * flutter * np.sin(math.pi * t / t[-1]) + return 0.95 * np.tanh(2 * (1.4 * whoosh + 0.3 * hum)) + + +def sound_bow_heavy(): + t = time_axis(0.45) + string = np.sin(sweep_phase(t, 150, 105)) + 0.6 * np.sin(sweep_phase(t, 300, 210)) + 0.3 * np.sin(sweep_phase(t, 450, 315)) + thwack = lowpass(noise(t, 58), 0.3) * envelope(t, 0.0005, 14) + return 0.95 * np.tanh(1.6 * (0.5 * string * envelope(t, 0.002, 2) + 0.9 * thwack)) + + +def sound_uzi_heavy(): + t = time_axis(0.2) + burst = np.zeros_like(t) + for shot in range(5): + start = shot * 0.03 + after = np.maximum(t - start, 0) + gate = (t >= start) & (after < 0.05) + pop = square(2 * math.pi * (260 - 40 * shot) * after) * (1 - after / 0.05) ** 3 + crack = lowpass(noise(t, 59 + shot), 0.5) * (1 - after / 0.05) ** 5 + burst += gate * (0.4 * pop + 0.6 * crack) + thump = np.sin(sweep_phase(t, 100, 45)) * envelope(t, 0.001, 3) + return 0.95 * np.tanh(2 * (burst + 0.6 * thump)) + + +def sound_shotgun_heavy(): + t = time_axis(0.85) + boom_t = t[t < 0.6] + blast = highpass(noise(boom_t, 64), 0.2) * envelope(boom_t, 0.0003, 16) + body = lowpass(noise(boom_t, 65), 0.07) * envelope(boom_t, 0.001, 4) + boom = np.sin(sweep_phase(boom_t, 80, 28)) * envelope(boom_t, 0.001, 4) + shot = np.zeros_like(t) + shot[: len(boom_t)] = 0.95 * np.tanh(4 * (0.7 * blast + 1.6 * body + 1.3 * boom)) + pump = sound_clack(t, 0.55, 1200, 66) + sound_clack(t, 0.7, 1700, 67) + return shot + 0.6 * pump + + +def sound_rocket_heavy(): + t = time_axis(0.8) + ignite = np.sin(sweep_phase(t, 65, 22)) * envelope(t, 0.002, 4) + roar = lowpass_sweep(noise(t, 68), 0.02, 0.25) * np.clip(t / 0.15, 0, 1) * (1 - t / t[-1]) ** 1.2 + return 0.95 * np.tanh(2.2 * (1.0 * ignite + 1.3 * roar)) + + +def sound_break(): + t = time_axis(0.35) + crunch = lowpass(noise(t, 70), 0.4) * envelope(t, 0.001, 5) + rubble = (np.random.default_rng(71).random(len(t)) > 0.97) * envelope(t, 0.01, 2) * 0.6 + thump = np.sin(sweep_phase(t, 150, 50)) * envelope(t, 0.001, 6) + return 0.9 * np.tanh(1.8 * (1.0 * crunch + rubble + 0.9 * thump)) + + +def sound_fall(): + t = time_axis(0.45) + whistle = np.sin(sweep_phase(t, 1300, 250)) * envelope(t, 0.01, 1.5) + poof = lowpass(noise(t, 72), 0.15) * envelope(np.maximum(t - 0.3, 0), 0.005, 4) * (t >= 0.3) + return 0.35 * whistle + 0.9 * poof + + +def sound_finish(): + notes = [523, 659, 784, 1047, 1319] + t = time_axis(0.9) + signal = np.zeros_like(t) + for i, frequency in enumerate(notes): + start = i * 0.08 + after = np.maximum(t - start, 0) + signal += (t >= start) * (square(2 * math.pi * frequency * after) * 0.5 + np.sin(2 * math.pi * frequency * 2 * after) * 0.5) * envelope(after, 0.003, 3) + return 0.22 * signal + + +def sound_duck(): + t = time_axis(0.14) + swish = lowpass_sweep(noise(t, 80), 0.25, 0.04) * np.sin(math.pi * t / t[-1]) + thump = np.sin(sweep_phase(t, 160, 90)) * envelope(np.maximum(t - 0.08, 0), 0.001, 6) * (t >= 0.08) + return 0.45 * swish + 0.35 * thump + + +def sound_unduck(): + t = time_axis(0.1) + return 0.35 * lowpass_sweep(noise(t, 81), 0.05, 0.3) * np.sin(math.pi * t / t[-1]) + + +def sound_crawl(): + t = time_axis(0.06) + return 0.6 * lowpass(noise(t, 82), 0.08) * np.sin(math.pi * t / t[-1]) + + +def sound_roll(): + t = time_axis(0.09) + bump = np.sin(sweep_phase(t, 130, 70)) * envelope(t, 0.002, 5) + scrape = lowpass(noise(t, 83), 0.1) * envelope(t, 0.002, 3) + return 0.5 * bump + 0.3 * scrape + + +def sound_impact_blaster(): + t = time_axis(0.12) + pop = np.sin(sweep_phase(t, 1100, 300)) * envelope(t, 0.0005, 4) + fizz = highpass(noise(t, 84), 0.5) * envelope(t, 0.001, 3) + return 0.35 * pop + 0.3 * fizz + + +def sound_impact_bullet(): + t = time_axis(0.07) + tick = square(sweep_phase(t, 2200, 900)) * envelope(t, 0.0003, 6) + chip = lowpass(noise(t, 85), 0.7) * envelope(t, 0.0003, 5) + return 0.15 * tick + 0.4 * chip + + +def sound_impact_arrow(): + t = time_axis(0.2) + thunk = np.sin(sweep_phase(t, 260, 140)) * envelope(t, 0.0005, 6) + knock = lowpass(noise(t, 86), 0.3) * envelope(t, 0.0005, 8) + quiver = np.sin(2 * math.pi * 180 * t) * np.sin(2 * math.pi * 22 * t) * envelope(t, 0.01, 2) + return 0.6 * thunk + 0.5 * knock + 0.15 * quiver + + +def sound_impact_boomerang(): + t = time_axis(0.15) + clonk = np.sin(2 * math.pi * 520 * t) + 0.6 * np.sin(2 * math.pi * 780 * t) + knock = lowpass(noise(t, 87), 0.4) * envelope(t, 0.0005, 10) + return 0.3 * clonk * envelope(t, 0.0005, 5) + 0.4 * knock + + +def sound_impact_mine(): + t = time_axis(0.12) + return 0.6 * sound_clack(t, 0, 1300, 89) + + +def sound_punch(): + t = time_axis(0.09) + whiff = lowpass_sweep(noise(t, 61), 0.5, 0.08) * envelope(t, 0.002, 3) + thud = np.sin(sweep_phase(t, 220, 90)) * envelope(t, 0.002, 5) + return 0.7 * whiff + 0.6 * thud + + +def sound_uppercut(): + t = time_axis(0.45) + rise = lowpass_sweep(noise(t, 62), 0.04, 0.4) * np.sin(math.pi * t / t[-1]) ** 0.6 + roar = np.sin(sweep_phase(t, 90, 260)) * envelope(t, 0.01, 2.5) + return 0.95 * np.tanh(1.8 * (1.2 * rise + 0.6 * roar)) + + +def mix(signal, heavy): + peak = max(1e-6, np.max(np.abs(signal))) + return (0.95 if heavy else 0.75) * signal / peak + + +def thump(t, start, end, power): + return np.sin(sweep_phase(t, start, end)) * envelope(t, 0.001, power) + + +def ring(t, frequencies, power): + return sum(np.sin(2 * math.pi * f * t) / (i + 1) for i, f in enumerate(frequencies)) * envelope(t, 0.001, power) + + +def burst(t, seed, amount, power): + return lowpass(noise(t, seed), amount) * envelope(t, 0.0005, power) + + +# Hit sounds by hit type, each a light and a heavy (h = 1) take on the type's character. +def hit_blunt(h): + t = time_axis(0.18 + 0.2 * h) + return mix(thump(t, 300 - 120 * h, 110 - 60 * h, 3) + 0.7 * burst(t, 71, 0.15, 5) + h * 0.5 * burst(t, 72, 0.5, 9), h) + + +def hit_slash(h): + t = time_axis(0.16 + 0.14 * h) + return mix(ring(t, [2400 - 800 * h, 3600 - 1200 * h, 5100], 5) + 0.8 * burst(t, 70, 0.6, 8) + h * thump(t, 140, 60, 4), h) + + +def hit_pierce(h): + t = time_axis(0.1 + 0.12 * h) + pierce = highpass(noise(t, 74), 0.2) * envelope(t, 0.0005, 10) + return mix(pierce + 0.6 * np.sin(sweep_phase(t, 1300, 500)) * envelope(t, 0.001, 5) + h * thump(t, 150, 60, 4), h) + + +def hit_lash(h): + t = time_axis(0.08 + 0.1 * h) + crack = highpass(noise(t, 75), 0.35) * envelope(t, 0.0003, 14) + return mix(crack + 0.35 * square(sweep_phase(t, 2200, 700)) * envelope(t, 0.001, 8) + h * 0.7 * thump(t, 180, 70, 5), h) + + +def hit_bullet(h): + if h: + return hit_shotgun_heavy() + t = time_axis(0.05) + tick = burst(t, 80, 0.8, 12) + 0.5 * np.sin(2 * math.pi * 1100 * t) * envelope(t, 0.0005, 10) + return mix(tick, h) + + +def hit_energy(h): + t = time_axis(0.12 + 0.16 * h) + zap = square(sweep_phase(t, 1800 - 600 * h, 300 - 200 * h)) * envelope(t, 0.001, 3) + return mix(0.6 * zap + 0.4 * burst(t, 79, 0.7, 8) + h * 0.6 * thump(t, 120, 50, 3), h) + + +def hit_fire(h): + t = time_axis(0.22 + 0.25 * h) + hiss = highpass(noise(t, 82), 0.1) * envelope(t, 0.01, 2) + crackle = (np.random.default_rng(83).uniform(0, 1, len(t)) > 0.996) * envelope(t, 0.001, 1.5) + return mix(0.6 * hiss + 0.9 * crackle + h * 0.6 * burst(t, 84, 0.06, 2), h) + + +def hit_explosive(h): + t = time_axis(0.25 + 0.25 * h) + return mix(burst(t, 85, 0.08 - 0.03 * h, 2.5) + 0.9 * thump(t, 90 - 25 * h, 40 - 15 * h, 2.5), h) + + +def hit_magic(h): + t = time_axis(0.14 + 0.16 * h) + shimmer = ring(t, [1320 - 300 * h, 1980 - 450 * h, 2640], 6) * (1 + 0.4 * np.sin(2 * math.pi * 30 * t)) + return mix(shimmer + 0.6 * burst(t, 90, 0.3, 7) + h * thump(t, 180, 70, 3), h) + + +# A shotgun blast's heavy thump, for heavy bullet hits. +def hit_shotgun_heavy(): + t = time_axis(0.34) + return mix(burst(t, 81, 0.15, 4) + thump(t, 90, 35, 3), 1) + + +# The Grimoire's spells: wisps appearing, a wisp setting off, the teleport gathering, and the arrival pop. +def sound_cast(): + t = time_axis(0.3) + chime = sum(np.sin(2 * math.pi * f * t) * envelope(t - d, 0.002, 4) * (t >= d) for f, d in [(880, 0), (1320, 0.05), (1760, 0.1)]) + return 0.35 * chime + + +def sound_wisp(): + t = time_axis(0.12) + return 0.3 * np.sin(sweep_phase(t, 600, 1400)) * envelope(t, 0.002, 4) + + +def sound_teleport_warp(): + t = time_axis(0.38) + rise = np.sin(sweep_phase(t, 300, 1500)) * (1 + 0.5 * np.sin(2 * math.pi * 24 * t)) + return 0.35 * rise * np.sin(math.pi * t / t[-1]) ** 0.5 + + +def sound_teleport(): + t = time_axis(0.3) + pop = thump(t, 500, 90, 4) + 0.7 * burst(t, 91, 0.25, 6) + sparkle = 0.4 * ring(t, [2200, 3300], 7) + return 0.8 * np.tanh(pop + sparkle) + + +HIT_SOUNDS = {name.removeprefix("hit_"): globals()[name] for name in + ["hit_blunt", "hit_slash", "hit_pierce", "hit_lash", "hit_bullet", "hit_energy", "hit_fire", "hit_explosive", + "hit_magic"]} + + +def hits_save(): + for name, sound in HIT_SOUNDS.items(): + ogg_save(f"hit_{name}", sound(0)) + ogg_save(f"hit_{name}_heavy", sound(1)) + + +def sound_select(): + t = time_axis(0.04) + return 0.25 * square(2 * math.pi * 1000 * t) * envelope(t, 0.001, 2) + + +def ogg_save(name, signal): + signal = np.clip(signal, -1, 1) + pcm = (signal * 32767).astype(np.int16) + with tempfile.TemporaryDirectory() as directory: + wavPath = pathlib.Path(directory) / "sound.wav" + with wave.open(str(wavPath), "wb") as file: + file.setnchannels(1) + file.setsampwidth(2) + file.setframerate(RATE) + file.writeframes(pcm.tobytes()) + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(wavPath), "-c:a", "libvorbis", "-q:a", "4", str(SFX / f"{name}.ogg")], + check=True, + ) + + +def sfx_generate(): + SFX.mkdir(parents=True, exist_ok=True) + ogg_save("jump", sound_jump()) + ogg_save("land", sound_land()) + ogg_save("step", sound_step()) + ogg_save("swing", sound_whoosh(0.25, 0.02, 0.3, 3)) + ogg_save("slash", sound_whoosh(0.15, 0.1, 0.6, 4)) + ogg_save("poke", sound_poke()) + ogg_save("blast", sound_blast()) + ogg_save("beam", sound_beam()) + ogg_save("boomerang", sound_boomerang()) + hits_save() + ogg_save("slam", sound_slam()) + ogg_save("charge", sound_charge()) + ogg_save("ready", sound_ready()) + ogg_save("select", sound_select()) + ogg_save("bow", sound_twang()) + ogg_save("rocket", sound_rocket()) + ogg_save("grapple", sound_grapple()) + ogg_save("latch", sound_latch()) + ogg_save("explosion", sound_explosion()) + ogg_save("flail", sound_flail()) + ogg_save("flail_land", sound_flail_land()) + ogg_save("dash", sound_dash()) + ogg_save("climb", sound_climb()) + ogg_save("echo", sound_echo()) + ogg_save("equip", sound_equip()) + ogg_save("unequip", sound_unequip()) + ogg_save("swap", sound_swap()) + ogg_save("ricochet", sound_ricochet()) + ogg_save("pound", sound_pound()) + ogg_save("zap", sound_zap()) + ogg_save("uzi", sound_uzi()) + ogg_save("mine", sound_mine()) + ogg_save("death", sound_death()) + ogg_save("bounce", sound_bounce()) + ogg_save("shotgun", sound_shotgun()) + ogg_save("whip", sound_whip()) + ogg_save("whip_blast", sound_whip_blast()) + ogg_save("flame", sound_flame()) + ogg_save("fireball", sound_fireball()) + ogg_save("detonate", sound_detonate()) + ogg_save("detonate_empty", sound_detonate_empty()) + ogg_save("slash_heavy", sound_slash_heavy()) + ogg_save("swing_heavy", sound_swing_heavy()) + ogg_save("flail_heavy", sound_flail_heavy()) + ogg_save("poke_heavy", sound_poke_heavy()) + ogg_save("whip_heavy", sound_whip_heavy()) + ogg_save("grapple_heavy", sound_grapple_heavy()) + ogg_save("boomerang_heavy", sound_boomerang_heavy()) + ogg_save("bow_heavy", sound_bow_heavy()) + ogg_save("uzi_heavy", sound_uzi_heavy()) + ogg_save("shotgun_heavy", sound_shotgun_heavy()) + ogg_save("rocket_heavy", sound_rocket_heavy()) + ogg_save("break", sound_break()) + ogg_save("duck", sound_duck()) + ogg_save("unduck", sound_unduck()) + ogg_save("crawl", sound_crawl()) + ogg_save("roll", sound_roll()) + ogg_save("fall", sound_fall()) + ogg_save("finish", sound_finish()) + ogg_save("impact_blaster", sound_impact_blaster()) + ogg_save("impact_bullet", sound_impact_bullet()) + ogg_save("impact_arrow", sound_impact_arrow()) + ogg_save("impact_boomerang", sound_impact_boomerang()) + ogg_save("impact_mine", sound_impact_mine()) + ogg_save("punch", sound_punch()) + ogg_save("uppercut", sound_uppercut()) + ogg_save("cast", sound_cast()) + ogg_save("wisp", sound_wisp()) + ogg_save("teleport_warp", sound_teleport_warp()) + ogg_save("teleport", sound_teleport()) + ogg_save("level_up", sound_arpeggio([523, 659, 784, 1047])) + ogg_save("level_down", sound_arpeggio([784, 622, 523, 392])) + + +if __name__ == "__main__": + sfx_generate() diff --git a/tools/balance_runs.py b/tools/balance_runs.py new file mode 100644 index 0000000..f0ae8fb --- /dev/null +++ b/tools/balance_runs.py @@ -0,0 +1,67 @@ +# Plays many seeded bot matches at once (the game's --balance runner, one process per seed) and adds up their tallies, +# flagging any weapon outside 45-55% win rate. Seeds start at SEED_BASE (default 100), so a run is repeatable and a +# fresh base checks results don't just fit one set of seeds. +# +# python tools/balance_runs.py [matches per process] [processes] [-- extra game arguments, like race] + +import math +import os +import pathlib +import re +import subprocess +import sys +import tempfile +from concurrent.futures import ThreadPoolExecutor + +ROOT = pathlib.Path(__file__).resolve().parent.parent +GAME = ROOT / "build" / "smack-a-brother" +TARGET = 0.05 + + +def run(seed, matches, extra, prefs): + env = dict(os.environ, RPG_SEED=str(seed), SDL_VIDEO_DRIVER="dummy", SDL_AUDIO_DRIVER="none", XDG_DATA_HOME=prefs) + result = subprocess.run([str(GAME), "--balance", str(matches)] + extra, env=env, capture_output=True, text=True) + return result.stdout + result.stderr + + +def main(): + args = sys.argv[1:] + extra = args[args.index("--") + 1:] if "--" in args else [] + args = args[:args.index("--")] if "--" in args else args + matches = int(args[0]) if args else 2500 + processes = int(args[1]) if len(args) > 1 else os.cpu_count() + base = int(os.environ.get("SEED_BASE", "100")) + with tempfile.TemporaryDirectory() as prefs, ThreadPoolExecutor(processes) as pool: + outputs = list(pool.map(lambda i: run(base + i, matches, extra, prefs), range(processes))) + + tallies = {"Weapons": {}, "Trinkets": {}} + total = stalls = 0 + for output in outputs: + section = None + for line in output.splitlines(): + if line.startswith("Weapons"): + section = "Weapons" + elif line.startswith("Trinkets"): + section = "Trinkets" + row = re.match(r"\s+(.+?)\s+[\d.]+% \+-\s*[\d.]+\s+\((\d+) wins / (\d+) picks, (\d+) stalls\)", line) + if row and section: + tally = tallies[section].setdefault(row.group(1), [0, 0, 0]) + for i in range(3): + tally[i] += int(row.group(2 + i)) + summary = re.search(r"(\d+) matches in .*?(\d+) hit the", line) + if summary: + total += int(summary.group(1)) + stalls += int(summary.group(2)) + + print(f"{total} matches, {stalls} stalled ({100 * stalls / max(total, 1):.1f}%), seeds {base}-{base + processes - 1}") + for section, tally in tallies.items(): + print(section) + for name, (wins, picks, stalled) in sorted(tally.items(), key=lambda item: -item[1][0] / max(item[1][1], 1)): + rate = wins / max(picks, 1) + margin = 1.96 * math.sqrt(rate * (1 - rate) / max(picks, 1)) + flag = " <--" if section == "Weapons" and abs(rate - 0.5) > TARGET else "" + print(f" {name:16} {100 * rate:5.1f}% +-{100 * margin:4.1f} ({picks} picks, {stalled} stalls){flag}") + + +if __name__ == "__main__": + main() diff --git a/tools/determinism_check.sh b/tools/determinism_check.sh new file mode 100755 index 0000000..866aa3b --- /dev/null +++ b/tools/determinism_check.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Checks the simulation is deterministic across builds and platforms, which desktop/web crossplay depends on. +# +# Fails if any source calls the maths library's transcendentals (src/dmath.h replaces them), then builds the game +# several ways and plays the same seeded bot matches in each (--balance with RPG_SEED), comparing the determinism hash +# they print: +# gcc GCC, optimised (the usual build) +# gcc-fma GCC for this CPU (with FMA if it has it, standing in for arm64's fused multiply-adds) +# clang-O0 Clang, unoptimised +# wasm Emscripten, run under node (needs EMSDK, or emsdk_env.sh sourced) +# +# Usage: tools/determinism_check.sh [matches per seed, default 20] [seeds, default "1 2 3"] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/build-check" +MATCHES="${1:-20}" +SEEDS="${2:-1 2 3}" + +banned=$(grep -nE "std::(sin|cos|tan|atan2|atan|asin|acos|exp|exp2|expm1|log|log2|log10|log1p|pow|hypot|cbrt|sinh|cosh|tanh|lerp)\(|\b(sinf|cosf|tanf|atan2f|powf|expf|logf|log2f)\(|SDL_(sin|cos|tan|atan2|atan|pow|exp|log)f?\(" \ + "$ROOT"/src/*.cpp "$ROOT"/src/*.h | grep -v "src/dmath.h" || true) +if [ -n "$banned" ]; then + echo "Maths library transcendentals in the source (use math_sin and friends from src/dmath.h):" + echo "$banned" + exit 1 +fi + +configure() # name, cmake arguments... +{ + local name="$1" + shift + if [ ! -f "$OUT/$name/build.ninja" ]; then + cmake -S "$ROOT" -B "$OUT/$name" -G Ninja "$@" > "$OUT/$name.log" 2>&1 + fi + cmake --build "$OUT/$name" >> "$OUT/$name.log" 2>&1 || { + echo "$name failed to build, see $OUT/$name.log" + exit 1 + } +} + +mkdir -p "$OUT" +names=(gcc gcc-fma clang-O0) +configure gcc -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ +configure gcc-fma -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ \ + -DCMAKE_CXX_FLAGS=-march=native -DCMAKE_C_FLAGS=-march=native +configure clang-O0 -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ +if [ -n "${EMSDK:-}" ] || command -v emcmake > /dev/null; then + [ -n "${EMSDK:-}" ] && source "$EMSDK/emsdk_env.sh" > /dev/null 2>&1 + if [ ! -f "$OUT/wasm/build.ninja" ]; then + emcmake cmake -S "$ROOT" -B "$OUT/wasm" -G Ninja -DCMAKE_BUILD_TYPE=Release -DRPG_NODE=ON > "$OUT/wasm.log" 2>&1 + fi + cmake --build "$OUT/wasm" >> "$OUT/wasm.log" 2>&1 || { + echo "wasm failed to build, see $OUT/wasm.log" + exit 1 + } + names+=(wasm) +else + echo "Emscripten not found (set EMSDK): skipping the wasm build." +fi + +hash_get() # name, seed +{ + local run=("$OUT/$1/smack-a-brother") + [ "$1" = wasm ] && run=(node "$OUT/wasm/smack-a-brother.js") + (cd "$OUT/$1" && RPG_SEED="$2" "${run[@]}" --balance "$MATCHES" 2>&1 | grep -oE "hash: [0-9a-f]+" | cut -d' ' -f2) +} + +failed=0 +for seed in $SEEDS; do + line="seed $seed:" + first="" + for name in "${names[@]}"; do + hash=$(hash_get "$name" "$seed") + line+=" $name $hash" + [ -z "$first" ] && first="$hash" + [ "$hash" != "$first" ] && failed=1 + done + echo "$line" +done +if [ "$failed" = 1 ]; then + echo "DESYNC: the builds disagree." + exit 1 +fi +echo "Deterministic: every build agrees." diff --git a/tools/fonts_export.py b/tools/fonts_export.py new file mode 100644 index 0000000..fcf5a3f --- /dev/null +++ b/tools/fonts_export.py @@ -0,0 +1,172 @@ +# Exports the game's fonts as atlases (resources/font/.png) with a .fnt file each (JSON) that the game reads +# them by: +# tiny hand-drawn here, 3 pixels wide with a descender row, for squeezing text in +# small pixel_purl.ttf at its native 16 pixels per em, the game's usual text +# big small at twice the size, pixel for pixel, for text drawn twice as big +# huge small at three times the size, for text drawn three times as big +# Each glyph sits in its own cell, 16 to a row, drawn where the game puts it relative to the line's top-left, so the +# PNGs can be redrawn by hand afterwards (keeping to the cells, or changing the .fnt to match). +# +# A .fnt file holds the image, the scale it's drawn for (which size of text the game picks it for; tiny has none, +# being picked by name), the line height, the outline's thickness, and per character its cell in the image (x, y, w, +# h), where the cell goes relative to the pen at the line's top (dx, dy), and how far the pen then moves (advance). +# +# Existing files are left alone, so hand edits survive; --force writes them all again. +# +# python tools/fonts_export.py [--force] + +import json +import pathlib +import sys + +from PIL import Image, ImageDraw, ImageFont + +ROOT = pathlib.Path(__file__).resolve().parent.parent +FONTS = ROOT / "resources" / "font" +TTF = FONTS / "pixel_purl.ttf" +FIRST = 32 +COUNT = 95 +COLUMNS = 16 +EM_PIXELS = 16 +LINE_HEIGHT = 12 +TINY_LINE_HEIGHT = 8 +TINY_CELL = 6 +TINY_SPACE = 2 + +# The tiny font: rows 0-4 are the body and row 5 descenders. +TINY = { + ' ': [], '!': ["#", "#", "#", ".", "#"], '"': ["#.#", "#.#"], '#': [".#.#.", "#####", ".#.#.", "#####", ".#.#."], + '$': [".##", "##.", ".#.", ".##", "##."], '%': ["#.#", "..#", ".#.", "#..", "#.#"], + '&': [".#..", "#.#.", ".#..", "#.##", ".##."], "'": ["#", "#"], '(': [".#", "#.", "#.", "#.", ".#"], + ')': ["#.", ".#", ".#", ".#", "#."], '*': ["...", "#.#", ".#.", "#.#"], '+': ["...", ".#.", "###", ".#."], + ',': ["..", "..", "..", "..", ".#", "#."], '-': ["...", "...", "###"], '.': [".", ".", ".", ".", "#"], + '/': ["..#", "..#", ".#.", "#..", "#.."], + '0': ["###", "#.#", "#.#", "#.#", "###"], '1': [".#.", "##.", ".#.", ".#.", "###"], + '2': ["##.", "..#", ".#.", "#..", "###"], '3': ["##.", "..#", ".#.", "..#", "##."], + '4': ["#.#", "#.#", "###", "..#", "..#"], '5': ["###", "#..", "##.", "..#", "##."], + '6': [".##", "#..", "###", "#.#", "###"], '7': ["###", "..#", ".#.", ".#.", ".#."], + '8': ["###", "#.#", "###", "#.#", "###"], '9': ["###", "#.#", "###", "..#", "##."], + ':': [".", "#", ".", "#"], ';': ["..", ".#", "..", ".#", "#."], '<': ["..#", ".#.", "#..", ".#.", "..#"], + '=': ["...", "###", "...", "###"], '>': ["#..", ".#.", "..#", ".#.", "#.."], '?': ["##.", "..#", ".#.", "...", ".#."], + '@': [".##.", "#.##", "#.##", "#...", ".##."], + 'A': [".#.", "#.#", "###", "#.#", "#.#"], 'B': ["##.", "#.#", "##.", "#.#", "##."], + 'C': [".##", "#..", "#..", "#..", ".##"], 'D': ["##.", "#.#", "#.#", "#.#", "##."], + 'E': ["###", "#..", "##.", "#..", "###"], 'F': ["###", "#..", "##.", "#..", "#.."], + 'G': [".##", "#..", "#.#", "#.#", ".##"], 'H': ["#.#", "#.#", "###", "#.#", "#.#"], + 'I': ["###", ".#.", ".#.", ".#.", "###"], 'J': ["..#", "..#", "..#", "#.#", ".#."], + 'K': ["#.#", "#.#", "##.", "#.#", "#.#"], 'L': ["#..", "#..", "#..", "#..", "###"], + 'M': ["#...#", "##.##", "#.#.#", "#...#", "#...#"], 'N': ["#..#", "##.#", "#.##", "#..#", "#..#"], + 'O': [".#.", "#.#", "#.#", "#.#", ".#."], 'P': ["##.", "#.#", "##.", "#..", "#.."], + 'Q': [".#.", "#.#", "#.#", "##.", ".##"], 'R': ["##.", "#.#", "##.", "#.#", "#.#"], + 'S': [".##", "#..", ".#.", "..#", "##."], 'T': ["###", ".#.", ".#.", ".#.", ".#."], + 'U': ["#.#", "#.#", "#.#", "#.#", "###"], 'V': ["#.#", "#.#", "#.#", "#.#", ".#."], + 'W': ["#...#", "#...#", "#.#.#", "##.##", "#...#"], 'X': ["#.#", "#.#", ".#.", "#.#", "#.#"], + 'Y': ["#.#", "#.#", ".#.", ".#.", ".#."], 'Z': ["###", "..#", ".#.", "#..", "###"], + '[': ["##", "#.", "#.", "#.", "##"], '\\': ["#..", "#..", ".#.", "..#", "..#"], ']': ["##", ".#", ".#", ".#", "##"], + '^': [".#.", "#.#"], '_': ["...", "...", "...", "...", "###"], '`': ["#.", ".#"], + 'a': ["...", "##.", ".##", "#.#", ".##"], 'b': ["#..", "#..", "##.", "#.#", "##."], + 'c': ["...", ".##", "#..", "#..", ".##"], 'd': ["..#", "..#", ".##", "#.#", ".##"], + 'e': ["...", ".#.", "###", "#..", ".##"], 'f': [".##", ".#.", "###", ".#.", ".#."], + 'g': ["...", ".##", "#.#", ".##", "..#", "##."], 'h': ["#..", "#..", "##.", "#.#", "#.#"], + 'i': ["#", ".", "#", "#", "#"], 'j': [".#", "..", ".#", ".#", ".#", "#."], 'k': ["#..", "#.#", "##.", "##.", "#.#"], + 'l': ["#.", "#.", "#.", "#.", ".#"], 'm': [".....", "##.#.", "#.#.#", "#.#.#", "#.#.#"], + 'n': ["...", "##.", "#.#", "#.#", "#.#"], 'o': ["...", ".#.", "#.#", "#.#", ".#."], + 'p': ["...", "##.", "#.#", "#.#", "##.", "#.."], 'q': ["...", ".##", "#.#", "#.#", ".##", "..#"], + 'r': ["...", "#.#", "##.", "#..", "#.."], 's': ["...", ".##", "##.", "..#", "##."], + 't': [".#.", "###", ".#.", ".#.", ".##"], 'u': ["...", "#.#", "#.#", "#.#", ".##"], + 'v': ["...", "#.#", "#.#", "#.#", ".#."], 'w': [".....", "#...#", "#.#.#", "#.#.#", ".#.#."], + 'x': ["...", "#.#", ".#.", ".#.", "#.#"], 'y': ["...", "#.#", "#.#", ".##", "..#", "##."], + 'z': ["...", "###", ".#.", "#..", "###"], '{': [".##", ".#.", "##.", ".#.", ".##"], '|': ["#", "#", "#", "#", "#"], + '}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."], +} + + +def characters(): + return [chr(FIRST + i) for i in range(COUNT)] + + +# Each glyph as rows of '#'/'.' with where its top-left sits relative to the pen at the line's top, and its advance. +def tiny_glyphs(): + glyphs = {} + for character in characters(): + rows = TINY[character] + width = max((len(row) for row in rows), default=0) + glyphs[character] = {"rows": rows, "left": 0, "top": 1, "advance": TINY_SPACE if character == " " else width + 1} + return glyphs + + +def small_glyphs(): + font = ImageFont.truetype(str(TTF), EM_PIXELS) + glyphs = {} + for character in characters(): + # Drawn with the line's top at the origin's y, as the game always has; the bounding box gives the pixels. + image = Image.new("L", (64, 64), 0) + draw = ImageDraw.Draw(image) + draw.fontmode = "1" + draw.text((16, 16), character, font=font, fill=255) + box = image.getbbox() + rows = [] if not box else ["".join("#" if image.getpixel((x, y)) else "." for x in range(box[0], box[2])) + for y in range(box[1], box[3])] + glyphs[character] = {"rows": rows, "left": box[0] - 16 if box else 0, "top": box[1] - 16 if box else 0, + "advance": round(font.getlength(character))} + return glyphs + + +# The glyphs at a whole multiple of their size, pixel for pixel. +def scaled(glyphs, factor): + return {character: {"rows": ["".join(c * factor for c in row) for row in glyph["rows"] for _ in range(factor)], + "left": glyph["left"] * factor, "top": glyph["top"] * factor, + "advance": glyph["advance"] * factor} + for character, glyph in glyphs.items()} + + +# Lays the glyphs out in equal cells (each as big as the furthest any glyph reaches, from the leftmost glyph's left and +# the line's top), a pixel apart, and writes the image and its .fnt. +def font_write(name, glyphs, scale, line_height, outline): + left = min(0, min(glyph["left"] for glyph in glyphs.values() if glyph["rows"])) + right = max(glyph["left"] + len(glyph["rows"][0]) for glyph in glyphs.values() if glyph["rows"]) + bottom = max(glyph["top"] + len(glyph["rows"]) for glyph in glyphs.values() if glyph["rows"]) + cell_w, cell_h = right - left, max(bottom, line_height) + rows = (COUNT + COLUMNS - 1) // COLUMNS + image = Image.new("RGBA", (COLUMNS * (cell_w + 1), rows * (cell_h + 1)), (0, 0, 0, 0)) + entries = {} + for i, character in enumerate(characters()): + glyph = glyphs[character] + x = i % COLUMNS * (cell_w + 1) + y = i // COLUMNS * (cell_h + 1) + for row_y, row in enumerate(glyph["rows"]): + for row_x, pixel in enumerate(row): + if pixel == "#": + image.putpixel((x + glyph["left"] - left + row_x, y + glyph["top"] + row_y), (255, 255, 255, 255)) + entries[character] = {"x": x, "y": y, "w": cell_w, "h": cell_h, "dx": left, "dy": 0, + "advance": glyph["advance"]} + image.save(FONTS / f"{name}.png") + header = {"image": f"{name}.png", "scale": scale, "lineHeight": line_height, "outline": outline} + if scale is None: + del header["scale"] + lines = ["{"] + [f' {json.dumps(key)}: {json.dumps(value)},' for key, value in header.items()] + [' "glyphs": {'] + lines += [f' {json.dumps(character)}: {json.dumps(entry)}{"," if i < COUNT - 1 else ""}' + for i, (character, entry) in enumerate(entries.items())] + lines += [" }", "}"] + (FONTS / f"{name}.fnt").write_text("\n".join(lines) + "\n") + print(f"{name}: {image.width}x{image.height}, cells {cell_w}x{cell_h}") + + +def main(): + isForced = "--force" in sys.argv + small = small_glyphs() + fonts = { + "tiny": (tiny_glyphs(), None, TINY_LINE_HEIGHT, 1), + "small": (small, 1, LINE_HEIGHT, 1), + "big": (scaled(small, 2), 2, LINE_HEIGHT * 2, 2), + "huge": (scaled(small, 3), 3, LINE_HEIGHT * 3, 3), + } + for name, (glyphs, scale, line_height, outline) in fonts.items(): + if not isForced and ((FONTS / f"{name}.png").exists() or (FONTS / f"{name}.fnt").exists()): + print(f"{name}: kept (--force to export again)") + continue + font_write(name, glyphs, scale, line_height, outline) + + +if __name__ == "__main__": + main() diff --git a/tools/icons_resize.py b/tools/icons_resize.py new file mode 100644 index 0000000..2d32379 --- /dev/null +++ b/tools/icons_resize.py @@ -0,0 +1,133 @@ +# Makes the small (16x16) and tiny (8x8) icon sheets from the full 24x24 weapon and trinket icon sheets, with an anm2 +# for each listing the same regions and animations at the new size. +# +# The icons are a black body with grey detail inside a one pixel white outline, all tinted when drawn. Shrinking that +# outline along with the rest would smear it, so only the body is shrunk (a cell is body where enough of what it covers +# was, grey where most of that was grey) and a fresh one pixel outline is drawn around it at the new size. Tiny icons +# have no room for that, so they're the shrunk silhouette in white, keeping black only where it's thick enough. +# +# python tools/icons_resize.py + +import json +import pathlib + +from PIL import Image + +ROOT = pathlib.Path(__file__).resolve().parent.parent +GFX = ROOT / "resources" / "gfx" +SHEETS = ["weapon_icons", "trinkets"] +SIZES = {"small": 16, "tiny": 8} +CELL = 24 +BODY_COVERAGE = 0.4 +SHAPE_COVERAGE = 0.5 +INNER_COVERAGE = 0.6 +WHITE = (255, 255, 255, 255) +GREY = (128, 128, 128, 255) +BLACK = (0, 0, 0, 255) + + +def is_body(pixel): + return pixel[3] > 0 and pixel[:3] != (255, 255, 255) + + +def coverage_get(source, x, y, scale): + """How much of a cell at the new size was opaque, body, and grey body, as shares of the cell.""" + opaque = covered = grey = total = 0.0 + for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))): + for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))): + # How much of the source pixel falls inside this cell. + weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \ + (min(sy + 1, (y + 1) * scale) - max(sy, y * scale)) + total += weight + pixel = source[sx, sy] + opaque += weight * (pixel[3] > 0) + if is_body(pixel): + covered += weight + grey += weight * (pixel[:3] == (128, 128, 128)) + return opaque / total, covered / total, grey / total + + +def icon_tiny(icon, size): + """Too small for an outline around a body: the whole silhouette in white, with black (or grey) left only where it's + thick enough to keep a white rim all round.""" + source = icon.load() + scale = CELL / size + cells = [[coverage_get(source, x, y, scale) for x in range(size)] for y in range(size)] + shape = [[cells[y][x][0] >= SHAPE_COVERAGE for x in range(size)] for y in range(size)] + result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + pixels = result.load() + for y in range(size): + for x in range(size): + if not shape[y][x]: + continue + opaque, covered, grey = cells[y][x] + inner = all(0 <= x + dx < size and 0 <= y + dy < size and shape[y + dy][x + dx] + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))) + if inner and covered >= INNER_COVERAGE: + pixels[x, y] = GREY if grey > covered / 2 else BLACK + else: + pixels[x, y] = WHITE + return result + + +def icon_shrink(icon, size): + source = icon.load() + scale = CELL / size + body = [[None] * size for _ in range(size)] + for y in range(size): + for x in range(size): + covered = grey = total = 0.0 + for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))): + for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))): + # How much of the source pixel falls inside this cell. + weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \ + (min(sy + 1, (y + 1) * scale) - max(sy, y * scale)) + total += weight + pixel = source[sx, sy] + if is_body(pixel): + covered += weight + grey += weight * (pixel[:3] == (128, 128, 128)) + if covered / total >= BODY_COVERAGE: + body[y][x] = GREY if grey > covered / 2 else BLACK + + result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + pixels = result.load() + for y in range(size): + for x in range(size): + if body[y][x]: + pixels[x, y] = body[y][x] + continue + touching = any(0 <= x + dx < size and 0 <= y + dy < size and body[y + dy][x + dx] + for dx in (-1, 0, 1) for dy in (-1, 0, 1)) + if touching: + pixels[x, y] = WHITE + return result + + +def main(): + for sheet in SHEETS: + art = json.loads((GFX / f"{sheet}.anm2").read_text()) + image = Image.open(GFX / f"{sheet}.png").convert("RGBA") + for suffix, size in SIZES.items(): + columns, rows = image.width // CELL, image.height // CELL + result = Image.new("RGBA", (columns * size, rows * size), (0, 0, 0, 0)) + for row in range(rows): + for column in range(columns): + icon = image.crop((column * CELL, row * CELL, (column + 1) * CELL, (row + 1) * CELL)) + shrink = icon_tiny if suffix == "tiny" else icon_shrink + result.paste(shrink(icon, size), (column * size, row * size)) + name = f"{sheet}_{suffix}" + result.save(GFX / f"{name}.png") + + shrunk = json.loads(json.dumps(art)) + spritesheet = shrunk["spritesheets"][0] + spritesheet["path"] = f"{name}.png" + for region in spritesheet["regions"]: + region["crop"] = [region["crop"][0] // CELL * size, region["crop"][1] // CELL * size] + region["size"] = [size, size] + (GFX / f"{name}.anm2").write_text(json.dumps(shrunk, indent=2) + "\n") + print(f"{name}: {result.width}x{result.height}") + + +if __name__ == "__main__": + main() diff --git a/tools/node_pre.js b/tools/node_pre.js new file mode 100644 index 0000000..8875fec --- /dev/null +++ b/tools/node_pre.js @@ -0,0 +1,5 @@ +// Headless node runs of the emscripten build: SDL looks for hints in the page's URL, which node doesn't have, and the +// game reads RPG_SEED and friends from the environment, which emscripten doesn't pass through by itself. +globalThis.window = globalThis.window || { location: { search: "" } }; +Module.preRun = Module.preRun || []; +Module.preRun.push(() => Object.assign(ENV, process.env)); diff --git a/tools/sfx_build.py b/tools/sfx_build.py new file mode 100644 index 0000000..04f0feb --- /dev/null +++ b/tools/sfx_build.py @@ -0,0 +1,366 @@ +# Builds the game's sound effects (resources/sfx), cut from the Sound Ideas Series 4000 and Guns libraries and the +# Universal Studios one (or synthesized, see tools/sfx_suites.py), then squeezed the way an old console would have to: +# resampled to a low rate with no filtering, and stored as SNES-style BRR (4-bit ADPCM, 16 samples in 9 bytes) before +# being played back with no interpolation. Each sound is one library track's sound (or a few, layered) starting at a +# given time, trimmed where it dies away or runs too long, faded, optionally pitched, and driven towards the loudness +# of the prototype's synthesized version (kept in tools/sfx_loudness.json). A few sounds were kept as they were made +# (from the synthesized set or the first library demo); those files are left alone. SOURCES.md lists where each came +# from and its size as BRR. tools/sfx_review.py auditions them. +# +# python tools/sfx_build.py [sound...] + +import json +import math +import pathlib +import subprocess +import sys +import tempfile +import wave + +import numpy as np + +import sfx_suites + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SFX = ROOT / "resources" / "sfx" +LOUDNESS = json.loads((ROOT / "tools" / "sfx_loudness.json").read_text()) +SOUNDS = pathlib.Path("/home/anon/sda/Media/Audio/Sounds") +LIBRARIES = [ + "SoundIdeasSeries4000", + "Sound Ideas - Guns Sound Effects Library (24 bit 48 khz)", + "Universal Studios Sound Effects Library (OGG)", +] +RATE = 44100 +OUT_RATE = 22050 +SAMPLE_RATE = 11025 +BRR_BLOCK = 16 +BRR_BYTES = 9 +BRR_SHIFT_MAX = 12 +FRAME = 0.002 +GAP = 0.05 +THRESHOLD = 0.12 +TAIL = 0.02 +FADE = 0.012 +TRUNCATED_FADE = 0.35 +LOUDNESS_WINDOW = 0.05 +PEAK = 0.95 +DRIVE = 2 + +# Each game sound: the library track's id ("4001_75_1", "GUNS03_78_2" or "US03-17-1"), the second its sound starts at +# (the start of the track's sound nearest it, or exactly there with "x"; none means its first sound), the longest it +# may run (it also stops where the next sound in the track starts, unless "j" joins them), and optionally a speed +# ("sp", which pitches it too), a gain ("g"), a sample rate ("r") and a longer fade ("fd", the share of it faded out). +# A list layers several, each with an "at" offset in seconds; a layer can be synthesized instead ({"synth": recipe, +# "a": {arguments}}, from tools/sfx_suites.py). {"keep": set} marks a sound whose file is kept as it was made. +PICKS = { + "beam": {"f": "4001_88_7", "s": 1.09, "n": 0.45}, + "blast": {"synth": "pew", "a": {"start": 1200, "end": 150, "seconds": 0.18, "duty": 0.125, "octave": 0.5}}, + "boomerang": {"f": "4001_75_4", "s": 1.004, "n": 0.3}, + "boomerang_heavy": {"f": "4001_75_8", "s": 1.331, "n": 0.5}, + "bounce": {"f": "4003_59_1", "s": 0.54, "n": 0.2}, + "bow": {"f": "US02-44-1", "s": 0.17, "n": 0.25}, + "bow_heavy": {"f": "US02-44-1", "s": 0.17, "n": 0.4, "sp": 0.8}, + "break": {"f": "US02-96-1", "s": 0.59, "n": 0.45}, + "cast": {"synth": "ember", "a": {}}, + "chat": {"synth": "beeps", "a": {"frequency": 1400, "count": 2, "length": 0.035, "gap": 0.055, "duty": 0.25}}, + "charge": {"synth": "warp", "a": {"start": 200, "end": 700, "seconds": 0.8, "wobble": 14, "depth": 0.3}}, + "crawl": {"keep": "synth"}, + "dash": {"f": "4003_47_4", "s": 0.17, "n": 0.25}, + "death": [{"f": "US03-34-1", "s": 0.17, "n": 0.45}, {"f": "4001_30_4", "s": 0.8, "n": 0.4, "at": 0.05, "g": 0.6}], + "detonate": {"keep": "synth"}, + "detonate_empty": {"f": "GUNS03_24_3", "s": 0.092, "n": 0.08, "r": 16000}, + "duck": {"keep": "synth"}, + "echo": {"synth": "shimmer", "a": {"frequency": 2000, "rate": 30, "duty": 0.25}}, + "equip": {"f": "GUNS03_39_1", "s": 0.032, "n": 0.3}, + "explosion": {"f": "4003_97_2", "s": 2.209, "n": 0.9, "r": 8000}, + "fall": {"f": "4001_78_3", "s": 1.433, "n": 0.8}, + "finish": {"keep": "demo1"}, + "fireball": {"keep": "demo1"}, + "flail": [{"f": "4003_47_4", "s": 0.17, "n": 0.3}, {"f": "4003_84", "s": 15.46, "n": 0.3, "j": True, "g": 0.6}], + "flail_heavy": [{"f": "4003_47_1", "s": 0.395, "n": 0.45, "sp": 0.7}, {"f": "4003_84", "s": 15.46, "n": 0.45, "j": True, "g": 0.7}], + "flail_land": {"n": 0.3, "f": "US03-37-2", "s": 0.782}, + "flame": {"f": "US03-17-2", "s": 0.68, "n": 0.15, "sp": 1.4, "g": 0.6}, + "grapple": [{"f": "US04-42-1", "s": 0.16, "n": 0.12}, {"f": "4003_84", "s": 15.46, "n": 0.25, "j": True, "sp": 1.3, "at": 0.03, "g": 0.7}], + "grapple_heavy": [{"f": "4003_47_1", "s": 0.395, "n": 0.4, "sp": 0.8}, {"f": "4003_84", "s": 11.94, "n": 0.35, "j": True, "g": 0.8}], + "impact_arrow": {"f": "4003_63_3", "s": 1.527, "n": 0.2}, + "impact_blaster": {"f": "GUNS03_65_8", "s": 0.016, "n": 0.12}, + "impact_boomerang": {"f": "4001_03_4", "s": 0.778, "n": 0.18}, + "impact_bullet": {"f": "GUNS03_64_1", "s": 0.036, "n": 0.08, "r": 16000}, + "impact_mine": {"f": "4003_62_5", "s": 1.269, "n": 0.15}, + "jump": {"keep": "synth"}, + "land": {"keep": "synth"}, + "latch": {"f": "US02-21-3", "s": 0.16, "n": 0.25}, + "level_down": {"synth": "arp", "a": {"notes": [784, 622, 523, 392], "seconds": 0.35, "duty": 0.25}}, + "level_up": {"synth": "arp", "a": {"notes": [392, 523, 659, 784, 1047], "seconds": 0.35, "duty": 0.25}}, + "mine": [{"f": "4003_47_3", "s": 0.18, "n": 0.15}, {"synth": "beeps", "a": {}, "at": 0.12}], + "wisp": {"f": "US02-45-5", "s": 0.18, "n": 0.25}, + "poke": {"f": "GUNS02_23_6", "s": 0.098, "n": 0.15}, + "poke_heavy": {"f": "GUNS02_24_1", "s": 0.14, "n": 0.35}, + "pound": {"synth": "thud", "a": {"start": 120, "end": 35, "seconds": 0.5, "body": 0.8}, "r": 8000}, + "punch": {"f": "4003_47_1", "s": 0.395, "n": 0.1}, + "ready": {"f": "US03-02-1", "s": 0.16, "n": 0.4}, + "ricochet": {"f": "GUNS03_77_14", "s": 9.393, "n": 0.3, "r": 16000}, + "rocket": {"f": "GUNS02_99_1", "s": 0.02, "n": 0.45}, + "rocket_heavy": {"f": "GUNS02_49_5", "s": 19.681, "n": 0.8, "r": 8000}, + "roll": {"keep": "synth"}, + "select": {"f": "4003_64_4", "s": 1.648, "n": 0.08, "g": 0.7, "r": 16000}, + "shotgun": [{"f": "GUNS02_18_2", "s": 0.01, "n": 0.45, "fd": 0.7}, {"f": "GUNS03_57_6", "s": 0.06, "n": 0.4, "at": 0.32, "g": 0.6, "j": True}], + "shotgun_heavy": [{"f": "GUNS02_19_2", "s": 0.012, "n": 0.5, "fd": 0.6}, {"f": "GUNS03_57_2", "s": 0.06, "n": 0.9, "sp": 0.8, "at": 0.42, "g": 0.6, "j": True, "fd": 0.25}], + "slam": {"f": "US03-39-2", "s": 0.16, "n": 0.6, "sp": 0.7}, + "slash": {"f": "4003_47_2", "s": 0.792, "n": 0.2}, + "slash_heavy": {"f": "4003_47_2", "s": 0.792, "n": 0.35, "sp": 0.75}, + "step": {"f": "4003_46_2", "s": 0.796, "n": 0.08, "g": 0.6, "r": 16000}, + "swap": {"keep": "demo1"}, + "swing": {"f": "4003_47_1", "s": 0.395, "n": 0.3}, + "swing_heavy": {"f": "4003_47_1", "s": 0.395, "n": 0.5, "sp": 0.7}, + "teleport": {"f": "US03-68-1", "s": 0.8, "n": 0.3}, + "teleport_warp": {"f": "US03-68-3", "s": 0.32, "n": 0.38, "sp": 3}, + "unduck": {"keep": "synth"}, + "unequip": {"f": "GUNS03_55_1", "s": 0.489, "n": 0.26}, + "uppercut": {"synth": "whoosh", "a": {"seconds": 0.5, "low": 0.02, "high": 0.8, "roar": 0.8, "roarStart": 120, "roarEnd": 480}}, + "uzi": {"keep": "demo1"}, + "uzi_heavy": {"f": "4002_86_3", "s": 1.826, "n": 0.3}, + "whip": {"f": "4003_57_4", "s": 2.045, "n": 0.25}, + "whip_heavy": {"keep": "demo1 (its whip)"}, + "zap": {"f": "US03-12-1", "s": 0.48, "n": 0.2, "j": True}, + # Hits, by type, light then heavy + "hit_blunt": {"f": "4003_49_1", "s": 0.407, "n": 0.15}, + "hit_blunt_heavy": {"f": "4003_48_6", "s": 0.836, "n": 0.3}, + "hit_slash": {"f": "4003_56_4", "s": 0.868, "n": 0.18}, + "hit_slash_heavy": {"f": "GUNS02_23_8", "s": 0.14, "n": 0.3}, + "hit_pierce": {"f": "GUNS03_64_1", "s": 0.036, "n": 0.12}, + "hit_pierce_heavy": {"f": "GUNS02_24_3", "s": 0.122, "n": 0.25}, + "hit_lash": {"f": "4003_52_5", "s": 1.213, "n": 0.1}, + "hit_lash_heavy": {"f": "4003_52_6", "s": 1.161, "n": 0.2}, + "hit_bullet": {"f": "GUNS03_67_7", "s": 0.329, "n": 0.06, "r": 16000}, + "hit_bullet_heavy": {"f": "GUNS03_65_12", "s": 0.147, "n": 0.3, "x": True}, + "hit_energy": {"f": "4002_27_5", "s": 0.806, "n": 0.14}, + "hit_energy_heavy": {"f": "4001_88_8", "s": 0.832, "n": 0.3}, + "hit_fire": {"f": "4003_94_4", "s": 3.14, "n": 0.3}, + "hit_fire_heavy": {"f": "GUNS02_46_1", "s": 0.85, "n": 0.47, "r": 8000}, + "hit_explosive": {"f": "4003_92_4", "s": 2.438, "n": 0.3}, + "hit_explosive_heavy": {"f": "4003_97_5", "s": 1.209, "n": 0.5, "r": 8000}, + "hit_magic": [{"f": "US03-17-1", "s": 0.17, "n": 0.15, "sp": 1.3, "r": 16000}, {"synth": "sparkle", "a": {}, "g": 0.7}], + "hit_magic_heavy": {"n": 0.3, "r": 16000, "f": "4001_88_9", "s": 1.026}, +} +# Any sounds still being chosen take the first of their candidates. +PICKS.update({name: suite[0][1] for name, suite in sfx_suites.SUITES.items()}) + + +def decode(path): + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(RATE), "-f", "f32le", "-"], + capture_output=True, check=True).stdout + return np.frombuffer(raw, dtype=np.float32).astype(np.float64) + + +TRACKS = {} + + +def track_find(track): + if not TRACKS: + for library in LIBRARIES: + for path in (SOUNDS / library).rglob("*"): + if path.suffix.lower() in (".wav", ".ogg"): + TRACKS[path.stem] = path + matches = [path for stem, path in TRACKS.items() if stem.endswith(" " + track) or stem.startswith(track + " ")] + assert len(matches) == 1, f"{track}: {len(matches)} tracks" + return matches[0] + + +# The separate sounds in a track: runs of frames louder than a share of the track's peak, merged across short gaps. +# Each is (start, end, peak) in samples. +def events_get(signal): + size = int(RATE * FRAME) + frames = np.abs(signal[: len(signal) // size * size]).reshape(-1, size).max(axis=1) + floor = np.percentile(frames, 10) + above = frames > max(frames.max() * THRESHOLD, floor * 5) + events = [] + for i, isAbove in enumerate(above): + if not isAbove: + continue + if events and i - events[-1][1] <= GAP / FRAME: + events[-1][1] = i + else: + events.append([i, i]) + return [(start * size, (end + 1) * size, frames[start:end + 1].max()) for start, end in events] + + +# Where the pick's sound lies in its track, in samples: from just before it starts until it has died away (or the next +# sound starts, or the length runs out), and whether the length cut it short. +def span_get(signal, pick): + events = events_get(signal) + at = int(pick["s"] * RATE) if pick.get("s") is not None else events[0][0] + nearest = min(events, key=lambda event: abs(event[0] - at)) + if pick.get("x"): + containing = [event for event in events if event[0] <= at < event[1]] + nearest = containing[0] if containing else nearest + start, eventEnd, peak = nearest + start = at if pick.get("x") else max(0, start - int(0.004 * RATE)) + limit = start + int(pick["n"] * pick.get("sp", 1) * RATE) + if pick.get("j"): + joined = [event for event in events if start <= event[0] < limit] + eventEnd, peak = max(event[1] for event in joined + [nearest]), max(event[2] for event in joined + [nearest]) + if eventEnd <= start: + # An exact start past the sounds found (in a long, even one): take it as one sound up to the length. + eventEnd, peak = min(limit, len(signal)), np.abs(signal[start:limit]).max() + following = [event[0] for event in events if event[0] > eventEnd] + end = following[0] if following else len(signal) + quiet = np.nonzero(np.abs(signal[eventEnd:end]) > peak * TAIL)[0] + end = min(end, eventEnd + (quiet[-1] if len(quiet) else 0) + int(FADE * RATE)) + return start, min(end, limit), limit < end + + +# The pick's sound, cut from its track (or synthesized) with its end faded: over its last third when the length cut it +# short, or over the share the pick asks. +def cut(pick): + if "synth" in pick: + piece, isTruncated = sfx_suites.SYNTHS[pick["synth"]](**pick.get("a", {})).astype(np.float64), False + else: + signal = decode(track_find(pick["f"])) + start, end, isTruncated = span_get(signal, pick) + piece = signal[start:end].copy() + speed = pick.get("sp", 1) + if speed != 1: + piece = np.interp(np.arange(0, len(piece) - 1, speed), np.arange(len(piece)), piece) + share = pick.get("fd", TRUNCATED_FADE * isTruncated) + fade = min(len(piece), max(int(FADE * RATE), int(len(piece) * share))) + piece[len(piece) - fade:] *= np.linspace(1, 0, fade) + return piece * pick.get("g", 1) + + +def loudness(signal): + size = int(LOUDNESS_WINDOW * RATE) + if len(signal) <= size: + return np.sqrt(np.mean(signal ** 2)) + power = np.convolve(signal ** 2, np.ones(size) / size, mode="valid") + return np.sqrt(power.max()) + + +def sound_build(name, picks): + pieces = [(cut(pick), pick.get("at", 0)) for pick in picks] + length = max(int(at * RATE) + len(piece) for piece, at in pieces) + mixed = np.zeros(length) + for piece, at in pieces: + mixed[int(at * RATE):int(at * RATE) + len(piece)] += piece + # Recordings peak far above their average where the game's sounds are dense, so matching loudness drives the peaks + # into a soft clipper, but only so far (as hot samples did on old consoles); past that they stay quieter. + target = LOUDNESS[name] + gain = min(target / max(loudness(mixed), 1e-9), PEAK * DRIVE / max(np.abs(mixed).max(), 1e-9)) + return PEAK * np.tanh(mixed * gain / PEAK) + + +# The SNES's BRR predictors: each block predicts its samples from the two before, in one of four ways. +def predict(kind, previous, before): + if kind == 1: + return previous * 15 / 16 + if kind == 2: + return previous * 61 / 32 - before * 15 / 16 + if kind == 3: + return previous * 115 / 64 - before * 13 / 16 + return 0 * previous + + +# Encodes 16-bit samples as BRR and decodes them again, as the console would hear them: each block of 16 keeps the +# predictor and shift (a power-of-two scale for its 4-bit steps) that land closest, every step clamped to 16 bits. +def brr(samples): + count = math.ceil(len(samples) / BRR_BLOCK) * BRR_BLOCK + samples = np.concatenate([samples, np.zeros(count - len(samples))]) + shifts = 2.0 ** np.arange(BRR_SHIFT_MAX + 1) + out = np.zeros(count) + previous, before = 0.0, 0.0 + for block in range(0, count, BRR_BLOCK): + target = samples[block:block + BRR_BLOCK] + best = None + for kind in range(4): + p = np.full(len(shifts), previous) + b = np.full(len(shifts), before) + decoded = np.zeros((len(shifts), BRR_BLOCK)) + for i in range(BRR_BLOCK): + guess = predict(kind, p, b) + nibble = np.clip(np.round((target[i] - guess) / shifts * 2), -8, 7) + value = np.clip(guess + nibble * shifts / 2, -32768, 32767) + decoded[:, i] = value + b, p = p, value + errors = ((decoded - target) ** 2).sum(axis=1) + choice = int(np.argmin(errors)) + if best is None or errors[choice] < best[0]: + best = (errors[choice], decoded[choice]) + out[block:block + BRR_BLOCK] = best[1] + previous, before = best[1][-1], best[1][-2] + return out, count // BRR_BLOCK * BRR_BYTES + + +# Squeezes a sound like an old console: down to its sample rate with no filtering (keeping the aliasing), through +# BRR, then held sample by sample (no interpolation) at the output rate. +def crunch(signal, rate): + low = np.interp(np.arange(0, len(signal) - 1, RATE / rate), np.arange(len(signal)), signal) + decoded, size = brr(np.round(low * 32767)) + held = decoded[np.minimum((np.arange(int(len(decoded) * OUT_RATE / rate)) * rate / OUT_RATE).astype(int), + len(decoded) - 1)] + return held / 32768, size + + +def save(path, signal): + with tempfile.TemporaryDirectory() as directory: + wavPath = pathlib.Path(directory) / "sound.wav" + with wave.open(str(wavPath), "wb") as file: + file.setnchannels(1) + file.setsampwidth(2) + file.setframerate(OUT_RATE) + file.writeframes((np.clip(signal, -1, 1) * 32767).astype(np.int16).tobytes()) + subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(wavPath), "-c:a", "libvorbis", "-q:a", "6", str(path)], + check=True) + + +# Builds one game sound from its picks into an ogg at path, returning its size as BRR. +def render(name, picks, path): + picks = picks if isinstance(picks, list) else [picks] + signal, size = crunch(sound_build(name, picks), picks[0].get("r", SAMPLE_RATE)) + save(path, signal) + return size, len(signal) / OUT_RATE + + +def picks_get(name): + picks = PICKS[name] + return picks if isinstance(picks, list) else [picks] + + +def source_describe(pick): + if "synth" in pick: + return f"synth {pick['synth']}" + return track_find(pick["f"]).stem + + +def main(): + names = [name for name in sys.argv[1:] if name in PICKS] or sorted(PICKS) + lines = ["# Sound sources", "", + "Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studios one (or synthesized), " + f"resampled with no filtering to {SAMPLE_RATE} Hz unless noted, stored as SNES-style BRR (4-bit ADPCM, " + f"{BRR_BLOCK} samples in {BRR_BYTES} bytes) and played back with no interpolation. Built by " + "tools/sfx_build.py.", "", + "| Sound | Source | From | Speed | Rate | BRR bytes |", "|---|---|---|---|---|---|"] + total = 0 + for name in sorted(PICKS): + picks = picks_get(name) + if "keep" in picks[0]: + lines.append(f"| {name} | kept from the {picks[0]['keep']} set | | | | |") + continue + path = SFX / f"{name}.ogg" + if name in names: + size, seconds = render(name, picks, path) + print(name, f"{seconds:.2f}s", f"{size} bytes") + else: + rate = picks[0].get("r", SAMPLE_RATE) + size = math.ceil(len(decode(path)) * rate / RATE / BRR_BLOCK) * BRR_BYTES + total += size + for pick in picks: + start = "" if "synth" in pick else f"{pick['s']:.2f}s" if pick.get("s") is not None else "first" + lines.append(f"| {name} | {source_describe(pick)} | {start} | {pick.get('sp', 1)}x | " + f"{picks[0].get('r', SAMPLE_RATE)} | {size} |") + lines += ["", f"The built sounds come to {total / 1024:.0f} KB as BRR."] + (SFX / "SOURCES.md").write_text("\n".join(lines) + "\n") + print(f"total {total / 1024:.0f} KB") + + +if __name__ == "__main__": + main() diff --git a/tools/sfx_library_demo.py b/tools/sfx_library_demo.py new file mode 100644 index 0000000..ec2ffdf --- /dev/null +++ b/tools/sfx_library_demo.py @@ -0,0 +1,260 @@ +# Builds the first library demo of the game's sound effects into build/sfx-sets/demo1 (for the sound review), next to +# the synthesized set in build/sfx-sets/synth. Each game sound is cut from one library track (or a few, layered): the track is split into its separate +# sounds by loudness, one is picked, trimmed and faded, optionally pitched, matched to the loudness of the game's current +# sound and bitcrushed. SOURCES.md beside the demo lists where each one came from, and compare.html plays each beside +# the game's current sound. +# +# python tools/sfx_library_demo.py [library folder] + +import pathlib +import subprocess +import sys +import tempfile +import wave + +import numpy as np + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SFX = ROOT / "build" / "sfx-sets" / "synth" +DEMO = ROOT / "build" / "sfx-sets" / "demo1" +LIBRARY = pathlib.Path.home() / "Trash/universal-studios-sound-effects-library/Universal Studios Sound Effects Library (OGG)" +RATE = 44100 +OUT_RATE = 22050 +CRUSH = "acrusher=bits=8:mode=log:samples=4:aa=0:mix=1" +FRAME = 0.002 +GAP = 0.05 +THRESHOLD = 0.12 +SIGNIFICANT = 0.3 +TRUNCATED_FADE = 0.35 +TAIL = 0.02 +FADE = 0.012 +LOUDNESS_WINDOW = 0.05 +PEAK = 0.95 + +# Each game sound: the library track's number, which of its sounds to take (by order among those at least a share as +# loud as its loudest, -1 for the last, or "loud" for the loudest) or a start in seconds, the longest it may run, a speed (which pitches it too) and a gain. A list layers several, each with an "at" +# offset in seconds. +PICKS = { + # Movement and feedback + "jump": {"f": "US02-79-1", "n": 0.22, "sp": 1.3}, + "land": {"f": "US02-52-1", "n": 0.15, "sp": 1.3}, + "step": {"f": "US02-52-2", "n": 0.06, "sp": 1.8, "g": 0.6}, + "crawl": {"f": "US02-80-1", "n": 0.1, "sp": 1.4, "g": 0.7}, + "duck": {"f": "US03-21-1", "n": 0.15}, + "unduck": {"f": "US03-21-1", "n": 0.12, "sp": 1.5}, + "roll": {"f": "US02-51-4", "e": "loud", "n": 0.12, "sp": 1.3}, + "dash": {"f": "US02-79-2", "n": 0.2, "sp": 1.2}, + "climb": {"f": "US02-62-1", "n": 0.14}, + "fall": {"f": "US02-87-2", "n": 0.7, "sp": 1.2}, + "bounce": {"f": "US02-68-1", "n": 0.3, "sp": 1.2}, + "pound": {"f": "US03-47-1", "n": 0.55}, + "slam": {"f": "US02-50-3", "n": 0.7}, + "death": {"f": "US02-73-1", "n": 0.9}, + "finish": {"f": "US02-22-3", "e": 1, "n": 1.0}, + "break": {"f": "US02-88-1", "n": 0.45}, + "ready": {"f": "US02-77-1", "n": 0.5, "sp": 1.3}, + "charge": {"f": "US03-69-2", "s": 3.0, "n": 0.8, "sp": 2.0}, + "select": {"f": "US02-64-1", "n": 0.06, "sp": 1.2, "g": 0.7}, + "swap": {"f": "US02-61-2", "n": 0.1}, + "equip": {"f": "US04-50-3", "n": 0.3}, + "unequip": {"f": "US04-41-1", "n": 0.26}, + "level_up": {"f": "US02-76-1", "n": 0.4}, + "level_down": {"f": "US02-86-3", "n": 0.4}, + "echo": {"f": "US02-77-2", "n": 0.3, "sp": 1.5, "g": 0.7}, + "ricochet": {"f": "US04-62-1", "n": 0.3}, + "zap": {"f": "US03-12-1", "n": 0.15}, + # Weapons firing + "slash": {"f": "US04-30-1", "n": 0.2, "sp": 1.3}, + "slash_heavy": {"f": "US03-74-1", "n": 0.45}, + "swing": {"f": "US02-60-1", "n": 0.3}, + "swing_heavy": {"f": "US02-60-2", "n": 0.6}, + "poke": {"f": "US03-79-1", "n": 0.15, "sp": 1.2}, + "poke_heavy": {"f": "US03-21-3", "n": 0.35}, + "whip": {"f": "US03-89-1", "n": 0.25}, + "whip_heavy": {"f": "US03-89-3", "n": 0.35}, + "whip_blast": {"f": "US04-11-1", "n": 0.5}, + "grapple": {"f": "US04-42-1", "n": 0.2}, + "grapple_heavy": {"f": "US04-18-1", "n": 0.3}, + "latch": {"f": "US03-35-1", "n": 0.2}, + "boomerang": {"f": "US02-79-3", "n": 0.3}, + "boomerang_heavy": {"f": "US02-79-4", "n": 0.5, "sp": 0.85}, + "bow": {"f": "US02-44-1", "n": 0.3}, + "bow_heavy": {"f": "US02-44-2", "n": 0.5, "sp": 0.9}, + "blast": {"f": "US03-59-4", "n": 0.15}, + "beam": {"f": "US03-59-2", "n": 0.45}, + "uzi": {"f": "US04-72-1", "n": 0.08, "g": 0.7}, + "uzi_heavy": {"f": "US04-81-1", "n": 0.3}, + "shotgun": [{"f": "US04-59-1", "n": 0.45}, {"f": "US04-51-1", "n": 0.35, "at": 0.35, "g": 0.6}], + "shotgun_heavy": [{"f": "US04-59-3", "n": 0.5}, {"f": "US04-51-2", "n": 0.35, "at": 0.45, "g": 0.6}], + "flame": {"f": "US03-17-1", "n": 0.15, "sp": 1.4, "g": 0.6}, + "fireball": {"f": "US03-17-2", "n": 0.5}, + "rocket": {"f": "US04-02-1", "n": 0.45}, + "rocket_heavy": {"f": "US04-07-2", "n": 0.8}, + "explosion": {"f": "US04-12-1", "n": 0.9}, + "mine": {"f": "US02-56-2", "e": "loud", "n": 0.25, "sp": 1.3}, + "detonate": {"f": "US03-82-1", "n": 0.12}, + "detonate_empty": {"f": "US04-51-3", "n": 0.08}, + "flail": {"f": "US04-50-4", "n": 0.3}, + "flail_heavy": {"f": "US03-39-1", "n": 0.45}, + "flail_land": {"f": "US03-39-2", "n": 0.25}, + "punch": {"f": "US02-55-2", "n": 0.1}, + "uppercut": {"f": "US02-87-1", "n": 0.45, "sp": 1.8}, + # Shots landing on walls + "impact_arrow": {"f": "US02-46-1", "n": 0.2}, + "impact_blaster": {"f": "US03-08-1", "n": 0.12}, + "impact_bullet": {"f": "US04-71-3", "n": 0.08}, + "impact_boomerang": {"f": "US02-70-2", "n": 0.18}, + "impact_mine": {"f": "US03-36-1", "n": 0.15}, + # Hits, by type, light then heavy + "hit_blunt": {"f": "US03-40-1", "n": 0.15}, + "hit_blunt_heavy": {"f": "US03-20-1", "n": 0.3}, + "hit_slash": {"f": "US03-78-1", "n": 0.18}, + "hit_slash_heavy": {"f": "US03-76-1", "n": 0.3}, + "hit_pierce": {"f": "US03-71-1", "n": 0.12}, + "hit_pierce_heavy": {"f": "US03-79-2", "n": 0.25}, + "hit_lash": {"f": "US03-91-2", "n": 0.1}, + "hit_lash_heavy": {"f": "US03-91-1", "e": "loud", "n": 0.2}, + "hit_bullet": {"f": "US04-71-2", "n": 0.06}, + "hit_bullet_heavy": {"f": "US03-20-3", "n": 0.35}, + "hit_energy": {"f": "US03-59-1", "e": -1, "n": 0.14}, + "hit_energy_heavy": {"f": "US03-08-2", "n": 0.3}, + "hit_fire": {"f": "US03-45-3", "n": 0.25}, + "hit_fire_heavy": {"f": "US03-11-1", "n": 0.47}, + "hit_explosive": {"f": "US04-11-2", "n": 0.3}, + "hit_explosive_heavy": {"f": "US04-01-1", "n": 0.5}, + "hit_magic": {"f": "US02-66-2", "n": 0.2}, + "hit_magic_heavy": {"f": "US02-22-4", "e": 1, "n": 0.35}, +} + + +def decode(path): + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(RATE), "-f", "f32le", "-"], + capture_output=True, check=True).stdout + return np.frombuffer(raw, dtype=np.float32).astype(np.float64) + + +def track_find(number): + matches = [path for path in LIBRARY.glob(f"{number} *.ogg")] + assert len(matches) == 1, f"{number}: {len(matches)} tracks" + return matches[0] + + +# The separate sounds in a track: runs of frames louder than a share of the track's peak, merged across short gaps. +# Each is (start, end, peak) in samples. +def events_get(signal): + size = int(RATE * FRAME) + frames = np.abs(signal[: len(signal) // size * size]).reshape(-1, size).max(axis=1) + floor = np.percentile(frames, 10) + above = frames > max(frames.max() * THRESHOLD, floor * 5) + events = [] + for i, isAbove in enumerate(above): + if not isAbove: + continue + if events and i - events[-1][1] <= GAP / FRAME: + events[-1][1] = i + else: + events.append([i, i]) + return [(start * size, (end + 1) * size, frames[start:end + 1].max()) for start, end in events] + + +# One sound from a track: from just before it starts until it has died away (or the next sound starts, or the length +# runs out), with its end faded (over its last third when the length cut it short). +def cut(pick): + signal = decode(track_find(pick["f"])) + if "s" in pick: + start = int(pick["s"] * RATE) + end = len(signal) + else: + events = events_get(signal) + loudest = max(event[2] for event in events) + significant = [event for event in events if event[2] >= loudest * SIGNIFICANT] + choice = pick.get("e", 0) + start, eventEnd, peak = max(significant, key=lambda event: event[2]) if choice == "loud" else significant[choice] + start = max(0, start - int(0.004 * RATE)) + following = [event[0] for event in events if event[0] > eventEnd] + end = following[0] if following else len(signal) + quiet = np.nonzero(np.abs(signal[eventEnd:end]) > peak * TAIL)[0] + end = min(end, eventEnd + (quiet[-1] if len(quiet) else 0) + int(FADE * RATE)) + speed = pick.get("sp", 1) + limit = start + int(pick["n"] * speed * RATE) + isTruncated = limit < end + piece = signal[start:min(end, limit)] + if speed != 1: + piece = np.interp(np.arange(0, len(piece) - 1, speed), np.arange(len(piece)), piece) + fade = min(len(piece), max(int(FADE * RATE), int(len(piece) * TRUNCATED_FADE * isTruncated))) + piece[len(piece) - fade:] *= np.linspace(1, 0, fade) + return piece * pick.get("g", 1), start / RATE + + +def loudness(signal): + size = int(LOUDNESS_WINDOW * RATE) + if len(signal) <= size: + return np.sqrt(np.mean(signal ** 2)) + power = np.convolve(signal ** 2, np.ones(size) / size, mode="valid") + return np.sqrt(power.max()) + + +def sound_build(name, picks): + picks = picks if isinstance(picks, list) else [picks] + pieces = [(cut(pick), pick.get("at", 0)) for pick in picks] + length = max(int(at * RATE) + len(piece) for (piece, _), at in pieces) + mixed = np.zeros(length) + for (piece, _), at in pieces: + mixed[int(at * RATE):int(at * RATE) + len(piece)] += piece + target = loudness(decode(SFX / f"{name}.ogg")) + mixed *= target / max(loudness(mixed), 1e-9) + mixed *= min(1, PEAK / max(np.abs(mixed).max(), 1e-9)) + return mixed, [(pick, start) for pick, ((_, start), _) in zip(picks, pieces)] + + +def save(name, signal): + with tempfile.TemporaryDirectory() as directory: + wavPath = pathlib.Path(directory) / "sound.wav" + with wave.open(str(wavPath), "wb") as file: + file.setnchannels(1) + file.setsampwidth(2) + file.setframerate(RATE) + file.writeframes((np.clip(signal, -1, 1) * 32767).astype(np.int16).tobytes()) + subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(wavPath), "-af", CRUSH, "-ar", str(OUT_RATE), "-ac", "1", + "-c:a", "libvorbis", "-q:a", "4", str(DEMO / f"{name}.ogg")], check=True) + + +def main(): + global LIBRARY + if len(sys.argv) > 1: + LIBRARY = pathlib.Path(sys.argv[1]) + DEMO.mkdir(exist_ok=True) + names = sorted(path.stem for path in SFX.glob("*.ogg")) + missing = [name for name in names if name not in PICKS] + if missing: + print("no pick, left out:", " ".join(missing)) + names = [name for name in names if name in PICKS] + + lines = ["# Demo sound sources", "", "Cut from the Universal Studios Sound Effects Library, bitcrushed " + f"(`{CRUSH}`, {OUT_RATE} Hz mono).", "", "| Sound | Track | From | Speed |", "|---|---|---|---|"] + for name in names: + signal, sources = sound_build(name, PICKS[name]) + save(name, signal) + for pick, start in sources: + lines.append(f"| {name} | {track_find(pick['f']).stem} | {start:.2f}s | {pick.get('sp', 1)}x |") + print(name, f"{len(signal) / RATE:.2f}s") + (DEMO / "SOURCES.md").write_text("\n".join(lines) + "\n") + compare_save(names) + + +# A page to audition the demo against the game's current sounds, side by side. +def compare_save(names): + rows = [] + for name in names: + picks = PICKS[name] if isinstance(PICKS[name], list) else [PICKS[name]] + sources = "
".join(track_find(pick["f"]).stem for pick in picks) + rows.append(f"{name}" + f"{sources}") + page = ("Sound demo" + "" + "".join(rows) + "
SoundSynthDemoSource
") + (DEMO / "compare.html").write_text(page) + + +if __name__ == "__main__": + main() diff --git a/tools/sfx_loudness.json b/tools/sfx_loudness.json new file mode 100644 index 0000000..c83d07a --- /dev/null +++ b/tools/sfx_loudness.json @@ -0,0 +1,90 @@ +{ + "beam": 0.17044314236053743, + "blast": 0.24890481151850824, + "boomerang": 0.10461090235376201, + "boomerang_heavy": 0.3401341787981372, + "bounce": 0.6426251387864429, + "bow": 0.24495408551749903, + "bow_heavy": 0.4804584475115079, + "break": 0.5392378913202743, + "cast": 0.1876306281551399, + "charge": 0.0790984928643554, + "chat": 0.09218711443216639, + "crawl": 0.05324911921254962, + "dash": 0.2106605191527588, + "death": 0.787756768647564, + "detonate": 0.1764709237313438, + "detonate_empty": 0.008933264277474212, + "duck": 0.0980983429921673, + "echo": 0.10293265337790097, + "equip": 0.2402013212689994, + "explosion": 0.44086896358304895, + "fall": 0.21862711600176718, + "finish": 0.18262529260342558, + "fireball": 0.4062622487902082, + "flail": 0.1657019224809587, + "flail_heavy": 0.4452444895308449, + "flail_land": 0.38757505555290345, + "flame": 0.09119219115218317, + "grapple": 0.16203657182537057, + "grapple_heavy": 0.39679184747645047, + "hit_blunt": 0.3078573988143571, + "hit_blunt_heavy": 0.3948699998974639, + "hit_bullet": 0.08490500921794567, + "hit_bullet_heavy": 0.45952005309841043, + "hit_energy": 0.28392106615252266, + "hit_energy_heavy": 0.3811099252475695, + "hit_explosive": 0.33158899126050223, + "hit_explosive_heavy": 0.4975502100028631, + "hit_fire": 0.16472828434508968, + "hit_fire_heavy": 0.22508716962779118, + "hit_lash": 0.09517886346885522, + "hit_lash_heavy": 0.21627789327456748, + "hit_magic": 0.1622504956303602, + "hit_magic_heavy": 0.26656201365829557, + "hit_pierce": 0.12470862213552299, + "hit_pierce_heavy": 0.237537828956519, + "hit_slash": 0.17091316665409148, + "hit_slash_heavy": 0.24362943078758206, + "impact_arrow": 0.24404777719753132, + "impact_blaster": 0.13537950507133106, + "impact_boomerang": 0.12863685627858468, + "impact_bullet": 0.07677357844394447, + "impact_mine": 0.10998498208203826, + "jump": 0.2528298468594914, + "land": 0.22052687735528456, + "latch": 0.1560633059746919, + "level_down": 0.27419143940135443, + "level_up": 0.2748142654599642, + "mine": 0.23833596796086057, + "poke": 0.19819490749097482, + "poke_heavy": 0.614626328062559, + "pound": 0.6446719355490375, + "punch": 0.19459954714458175, + "ready": 0.11338623528306933, + "ricochet": 0.1675567763120395, + "rocket": 0.3117591276048256, + "rocket_heavy": 0.7662131963112271, + "roll": 0.13607378249814625, + "select": 0.09218711443216639, + "shotgun": 0.8002504669161772, + "shotgun_heavy": 0.8814309014666232, + "slam": 0.5555170721984241, + "slash": 0.3046866383126178, + "slash_heavy": 0.5043262612182655, + "step": 0.0363354998815522, + "swap": 0.10673649790216319, + "swing": 0.20927146265655572, + "swing_heavy": 0.44252241940818604, + "teleport": 0.37291389671058267, + "teleport_warp": 0.28198297974711667, + "unduck": 0.055408922622340935, + "unequip": 0.14312458621013163, + "uppercut": 0.5136797484711186, + "uzi": 0.11412018210436048, + "uzi_heavy": 0.514054417772262, + "whip": 0.40578381800211316, + "whip_heavy": 0.7589056659487207, + "wisp": 0.10307115947937166, + "zap": 0.14675244965628048 +} diff --git a/tools/sfx_review.py b/tools/sfx_review.py new file mode 100644 index 0000000..2f981c7 --- /dev/null +++ b/tools/sfx_review.py @@ -0,0 +1,779 @@ +# A local page for reviewing the game's sounds (resources/sfx) and leaving notes on them. Each sound shows when it +# plays, plays beside the last round's version, the prototype's synthesized one and the first library demo's (those +# sets live in build/sfx-sets while the review lasts), and shows the library track it was cut from with the cut marked +# (click the waveform to hear any part, shift-click to suggest a new start). Its candidates (tools/sfx_suites.py, then +# the closest library sounds) can be auditioned, crunched exactly as the game's sound would be, and chosen. Verdicts, +# notes, suggested starts and choices save as they're made to tools/sfx_review_notes.json; the last round's notes +# (tools/sfx_review_round.json, with that round's sounds in build/sfx-sets/round) show beside them. +# +# python tools/sfx_review.py [port] [--no-browser] + +import hashlib +import http.server +import json +import math +import pathlib +import re +import subprocess +import sys +import threading +import urllib.parse +import webbrowser +from multiprocessing import Pool + +import numpy as np + +import sfx_build as retro +import sfx_suites + +ROOT = retro.ROOT +NOTES = ROOT / "tools" / "sfx_review_notes.json" +LAST_ROUND = max([int(path.stem.removeprefix("sfx_review_round")) for path in (ROOT / "tools").glob("sfx_review_round*.json")], + default=0) +PREVIOUS = ROOT / "tools" / f"sfx_review_round{LAST_ROUND}.json" +CACHE = ROOT / "build" / "sfx-review" +SETS = {"game": retro.SFX, "last": ROOT / "build" / "sfx-sets" / f"round{LAST_ROUND}", + "synth": ROOT / "build" / "sfx-sets" / "synth", "demo": ROOT / "build" / "sfx-sets" / "demo1"} +ANALYSIS_RATE = 22050 +WAVE_RATE = 8000 +WAVE_BINS = 900 +ALTERNATIVES = 4 + +# Each sound's group, and when the game plays it. +CONTEXT = { + "jump": ("Movement", "Jumping off the ground, a wall or in mid-air."), + "land": ("Movement", "Landing from a fall fast enough to kick up dust."), + "step": ("Movement", "Each footstep while running."), + "crawl": ("Movement", "Each shuffle while crawling (crouched and moving)."), + "duck": ("Movement", "Crouching down."), + "unduck": ("Movement", "Standing back up from a crouch."), + "roll": ("Movement", "Each roll while sliding down a slope crouched."), + "dash": ("Movement", "Wings: an air dash toward the cursor."), + "bounce": ("Movement", "A launched fighter bouncing off a wall or floor; also a Hammer head rebounding off a wall."), + "fall": ("Movement", "Race: falling into a pit (put back on safe ground)."), + "pound": ("Movement", "Iron Weights: the blast from a hard landing."), + "death": ("Match", "A fighter running out of health and bursting."), + "finish": ("Match", "Race: a fighter crossing the finish."), + "break": ("Match", "A breakable block being knocked out."), + "ready": ("Match", "A charge reaching full (the charge is ready to release)."), + "charge": ("Match", "While charging an attack, until it's full or let go."), + "select": ("Menu", "Menu clicks: tabs, buttons, toggles, picking a weapon slot, opening the menu."), + "swap": ("Menu", "Closing the menu; switching tabs."), + "equip": ("Menu", "Putting a weapon or trinket into a slot."), + "unequip": ("Menu", "Taking a weapon or trinket out of a slot."), + "level_up": ("Menu", "Levelling an item up."), + "level_down": ("Menu", "Levelling an item down."), + "echo": ("Trinkets", "Doubler: the delayed echo of an attack (replaces the attack's own sound)."), + "ricochet": ("Trinkets", "Ricochet: a shot bouncing off a wall."), + "zap": ("Trinkets", "Tesla Coil: lightning between your attacks hitting someone."), + "slash": ("Weapons", "Sword: a normal slash."), + "slash_heavy": ("Weapons", "Sword: the charged slash that travels forward."), + "swing": ("Weapons", "Hammer: a normal overhead swing."), + "swing_heavy": ("Weapons", "Hammer: the charged swing that grows and slams."), + "slam": ("Weapons", "Hammer: the charged swing's head slamming the ground at the end."), + "poke": ("Weapons", "Lance: a normal poke."), + "poke_heavy": ("Weapons", "Lance: the charged lunge."), + "punch": ("Weapons", "Fist: a normal punch (fast, repeats a lot)."), + "uppercut": ("Weapons", "Fist: the charged uppercut that launches you up."), + "flail": ("Weapons", "Flail: each lap of the ball spinning overhead while held."), + "flail_heavy": ("Weapons", "Flail: the charged throw of the ball."), + "flail_land": ("Weapons", "Flail: the thrown ball landing on a surface."), + "whip": ("Weapons", "Whip: a normal lash."), + "whip_heavy": ("Weapons", "Whip: the charged lash."), + "grapple": ("Weapons", "Grapple: firing the hook."), + "grapple_heavy": ("Weapons", "Grapple: firing the charged hook (plunges in and yanks you)."), + "latch": ("Weapons", "Grapple: the hook catching on a wall or fighter."), + "boomerang": ("Weapons", "Boomerang: a normal throw."), + "boomerang_heavy": ("Weapons", "Boomerang: the charged throw (you can stand on it)."), + "bow": ("Weapons", "Bow: a normal arrow."), + "bow_heavy": ("Weapons", "Bow: a fully drawn arrow."), + "uzi": ("Weapons", "Uzi: each shot (very rapid fire)."), + "uzi_heavy": ("Weapons", "Uzi: the charged burst."), + "shotgun": ("Weapons", "Shotgun: a normal blast of pellets (kicks you back)."), + "shotgun_heavy": ("Weapons", "Shotgun: the charged slug."), + "blast": ("Weapons", "Blaster: a normal energy bolt."), + "beam": ("Weapons", "Blaster: the charged beam."), + "flame": ("Weapons", "Flamethrower: each puff of flame (rapid)."), + "fireball": ("Weapons", "Flamethrower: the charged fireball."), + "rocket": ("Weapons", "Rocket Launcher: a normal rocket."), + "rocket_heavy": ("Weapons", "Rocket Launcher: the charged rocket."), + "explosion": ("Weapons", "Any explosion: rockets, fireballs, mines going off."), + "mine": ("Weapons", "Mine: placing a mine (charged attack)."), + "detonate": ("Weapons", "Mine: the detonator click that sets your mines off."), + "detonate_empty": ("Weapons", "Mine: the detonator clicking with no armed mines out."), + "cast": ("Weapons", "Grimoire: casting wisps (they join the orbit around you)."), + "wisp": ("Weapons", "Grimoire: each wisp setting off toward the cursor."), + "teleport_warp": ("Weapons", "Grimoire: the teleport gathering (a short warning before you vanish)."), + "teleport": ("Weapons", "Grimoire: arriving, with a small blast."), + "impact_arrow": ("Impacts", "An arrow sticking into a wall."), + "impact_blaster": ("Impacts", "A Blaster bolt hitting a wall."), + "impact_bullet": ("Impacts", "A bullet (Uzi, Shotgun) hitting a wall."), + "impact_boomerang": ("Impacts", "A boomerang hitting a wall."), + "impact_mine": ("Impacts", "A thrown mine landing."), + "hit_blunt": ("Hits", "Blunt hit under 40 damage: Hammer, Fist, Flail, Boomerang, Pebble Helmet contact."), + "hit_blunt_heavy": ("Hits", "Blunt hit of 40+ damage; also a launched fighter slamming hard into a wall."), + "hit_slash": ("Hits", "Slash hit under 40 damage: Sword."), + "hit_slash_heavy": ("Hits", "Slash hit of 40+ damage: Sword."), + "hit_pierce": ("Hits", "Pierce hit under 40 damage: Lance, Bow, Grapple."), + "hit_pierce_heavy": ("Hits", "Pierce hit of 40+ damage: Lance, Bow, Grapple."), + "hit_lash": ("Hits", "Lash hit under 40 damage: Whip."), + "hit_lash_heavy": ("Hits", "Lash hit of 40+ damage: Whip."), + "hit_bullet": ("Hits", "Bullet hit under 40 damage: Uzi, Shotgun pellets (very frequent)."), + "hit_bullet_heavy": ("Hits", "Bullet hit of 40+ damage: mostly the Shotgun's charged slug."), + "hit_energy": ("Hits", "Energy hit under 40 damage: Blaster."), + "hit_energy_heavy": ("Hits", "Energy hit of 40+ damage: Blaster."), + "hit_fire": ("Hits", "Fire hit under 40 damage: Flamethrower (frequent)."), + "hit_fire_heavy": ("Hits", "Fire hit of 40+ damage: Flamethrower's fireball."), + "hit_explosive": ("Hits", "Explosive hit under 40 damage: Rocket Launcher, Mine."), + "hit_explosive_heavy": ("Hits", "Explosive hit of 40+ damage: Rocket Launcher, Mine."), + "hit_magic": ("Hits", "Magic hit under 40 damage: Grimoire wisps and teleport blasts."), + "hit_magic_heavy": ("Hits", "Magic hit of 40+ damage: Grimoire."), +} +GROUPS = ["Movement", "Match", "Menu", "Trinkets", "Weapons", "Impacts", "Hits"] + +# Where to look for alternatives to each sound: library categories (by file name), and the length, brightness (Hz) +# and pitch direction a good one has. +CANDIDATES = { + "jump": (r"^Cartoon(Zip|Whizz)", .12, None, "up"), + "land": (r"^(BodyfallHuman|DropWood)", .1, 800, None), + "step": (r"^(CartoonFootsteps|FootstepsSpurs)", .05, None, None), + "crawl": (r"^(RipFabric|CartoonSquish)", .1, None, None), + "duck": (r"^Cartoon(Squish|Squeak)", .12, None, "down"), + "unduck": (r"^Cartoon(Squish|Squeak)", .1, None, "up"), + "roll": (r"^(CartoonRattle|Swish)", .15, None, None), + "dash": (r"^(CartoonWhizz|Swish)", .2, None, None), + "fall": (r"^Cartoon(Fall|Whistle)", .7, None, "down"), + "bounce": (r"^Cartoon(Boing|Bounce)|COMEDY - (HOLLOW|SINGLE HEAD) (BONK|DONK)", .25, None, None), + "pound": (r"^(CartoonImpact|CannonArtillery)|COMEDY - HOLLOW|ROCK - SET DOWN", .3, 600, None), + "slam": (r"^(CrashMetal|CrashGarbage)|CRASH, BUILDING|ROCK - SET DOWN|METAL, (BANG|HIT)", .5, None, None), + "death": (r"^(CartoonSplat|ClayBreaking)|CRASH, (CHINA|PIGGY)", .5, None, None), + "finish": (r"^Fanfare", 1.0, None, None), + "break": (r"^(GlassSmash|ClayBreaking)|CRASH, (BUILDING|CHINA|MISC)|ROCK - (SMALL|SINGLE|SEVERAL)", .4, None, None), + "ready": (r"^Cartoon(Bell|Bing)|COMEDY - (SMALL|METAL) PING|GLASS, TOAST", .4, None, None), + "charge": (r"^(CartoonWarble|CartoonWhistle|SpaceZap)|SPACE - (ASCENDING|INERTIA|SPACE BUZZ)|SLIDE WHISTLE UP", .8, None, "up"), + "select": (r"^(CartoonDit|CartoonBing|DropCoin)", .05, 3000, None), + "swap": (r"^(GunHolster|HandGunFoley|CoinRustle)", .1, None, None), + "equip": (r"^(HandGunFoley|MachineGunFoley|ShotgunFoley)", .25, None, None), + "unequip": (r"^(HandGunFoley|RifleFoley)", .2, None, None), + "level_up": (r"^Cartoon(Bell|Whistle|Bing)|SLIDE WHISTLE UP", .35, None, "up"), + "level_down": (r"^Cartoon(Whistle|Horn|Warble)|SLIDE WHISTLE DOWN", .35, None, "down"), + "echo": (r"^(CartoonWarble|SpaceZap)", .25, None, None), + "ricochet": (r"^BulletRicochet", .25, None, None), + "zap": (r"^SpaceZap|ELECTRICITY|RADIO STATIC", .15, 4000, None), + "slash": (r"^(Swish|CartoonWhizz)", .12, 2500, None), + "slash_heavy": (r"^(Bayonnet|Swish)", .3, None, None), + "swing": (r"^(Swish|CartoonWhizz)", .2, 1200, None), + "swing_heavy": (r"^(Swish|CartoonWhizz)", .4, 800, None), + "poke": (r"^(Bayonnet|StabHuman)", .1, None, None), + "poke_heavy": (r"^Bayonnet", .25, None, None), + "whip": (r"^Whip ", .12, None, None), + "whip_heavy": (r"^Whip ", .3, None, None), + "grapple": (r"^(ChainRattle|CartoonZip)|STRETCH WIRE|FOLEY - .*COCK", .15, None, None), + "grapple_heavy": (r"^(ChainRattle|CartoonZip)|STRETCH WIRE|FOLEY - .*COCK", .25, None, None), + "latch": (r"^ChainRattle|METAL, (BANG|HIT)|BRAKE DRUM|LEVER", .15, None, None), + "boomerang": (r"^(CartoonWhizz|Swish)", .3, None, None), + "boomerang_heavy": (r"^CartoonWhizz", .5, 800, None), + "bow": (r"^CartoonTwang|ARCHERY - .*TWANG|WIRE BREAK", .2, None, None), + "bow_heavy": (r"^CartoonTwang|ARCHERY - .*TWANG|LOW .*TWANG", .4, 1200, None), + "blast": (r"^(CartoonGun|SpaceZap)|SPACE, GUN SHOTS", .12, 3000, None), + "beam": (r"^SpaceZap", .45, None, None), + "uzi": (r"^MachineGun GUNS", .06, None, None), + "uzi_heavy": (r"^(MachineGun GUNS|GunMachineGun)", .3, None, None), + "shotgun": (r"^(ShotgunFoley|GunShotGun)|RIFLE, FOLEY", .3, None, None), + "shotgun_heavy": (r"^(ShotgunFoley|GunShotGun)|RIFLE, FOLEY", .5, None, None), + "flame": (r"^(US03-17|BulletPassBy)", .15, None, None), + "fireball": (r"^(BulletPassBy|US03-17)", .4, 1500, None), + "rocket": (r"^(RocketLauncher|MissileLauncher)", .45, None, None), + "rocket_heavy": (r"^(MissileLauncher|GrenadeLauncher)", .8, None, None), + "explosion": (r"^(Explosion GUNS|GrenadeHand|Explosion 4)", .9, None, None), + "mine": (r"^(DropCan|GunShell)|DOOR LOCK BEEP", .2, None, None), + "detonate": (r"^(CartoonDit|HandGunFoley)", .1, 3000, None), + "detonate_empty": (r"^(HandGunFoley|GunShell)", .05, None, None), + "flail": (r"^(ChainRattle|Swish)|METAL, HIT|AGAINST METAL", .3, None, None), + "flail_heavy": (r"^(ChainRattle|Swish)|METAL, HIT|METAL BUCKET", .45, 700, None), + "flail_land": (r"^(CartoonImpact|ChainRattle)|METAL, (BANG|HIT)", .2, None, None), + "punch": (r"^(Swish|CartoonWhizz)", .08, 2000, None), + "uppercut": (r"^Cartoon(Whizz|Zip)", .4, None, "up"), + "cast": (r"FIRE, FLASH|FLAMING ARROW|ROCKET - .*IGNITE", .4, None, None), + "wisp": (r"FIRE, FLASH|FLAMING ARROW|^BulletPassBy", .1, None, None), + "teleport": (r"^(CartoonZip|SpaceZap)|SPACE - DESCENDING|LASER", .2, None, "down"), + "teleport_warp": (r"^(CartoonWarble|SpaceZap)|SPACE - (ASCENDING|INERTIA)", .2, None, "up"), + "impact_arrow": (r"^(BulletImpact|DropWood)", .1, 1500, None), + "impact_blaster": (r"^(SpaceZap|BulletImpact)", .08, 4000, None), + "impact_bullet": (r"^BulletImpact", .05, None, None), + "impact_boomerang": (r"^(CartoonBonk|DropWood)", .12, None, None), + "impact_mine": (r"^(DropCan|GunShell)", .1, None, None), + "hit_blunt": (r"^(PunchBody|HitHuman|CartoonBonk|PunchFace)", .12, None, None), + "hit_blunt_heavy": (r"^(PunchBody|HitHuman|CartoonImpact)", .25, 800, None), + "hit_slash": (r"^(StabHuman|Bayonnet|RipFabric)", .08, None, None), + "hit_slash_heavy": (r"^(StabHuman|Bayonnet|RipFabric)", .2, None, None), + "hit_pierce": (r"^(StabHuman|Bayonnet|BulletImpact)", .1, None, None), + "hit_pierce_heavy": (r"^(StabHuman|Bayonnet)", .2, None, None), + "hit_lash": (r"^(SlapFace|Whip )", .08, None, None), + "hit_lash_heavy": (r"^(SlapFace|Whip )", .15, None, None), + "hit_bullet": (r"^(BulletImpact|PaintballGunImp)", .05, None, None), + "hit_bullet_heavy": (r"^(BulletImpact|HitHuman)", .3, None, None), + "hit_energy": (r"^(SpaceZap|CartoonGun)", .1, None, None), + "hit_energy_heavy": (r"^SpaceZap", .25, None, None), + "hit_fire": (r"^(US03-17|BulletPassBy)", .25, None, None), + "hit_fire_heavy": (r"^(US03-17|GrenadeHand)", .45, None, None), + "hit_explosive": (r"^(GrenadeHand|Explosion |MortarCannon)", .3, None, None), + "hit_explosive_heavy": (r"^(Explosion |CannonArtillery)", .5, None, None), + "hit_magic": (r"^CartoonBing|ELECTRICITY|RADIO STATIC", .1, 3500, None), + "hit_magic_heavy": (r"^SpaceZap|ELECTRICITY", .25, None, None), +} + + +def decode(path, rate): + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(rate), "-f", "f32le", "-"], + capture_output=True).stdout + return np.frombuffer(raw, dtype=np.float32).astype(np.float64) + + +def track_id(stem): + match = re.search(r"(\d{4}_\d+(?:_\d+)?|GUNS\d+_\d+(?:_\d+)?|US\d\d-\d\d-\d+)", stem) + return match.group(1) if match else stem + + +def centroid(piece): + if len(piece) < 256: + return 0.0 + spectrum = np.abs(np.fft.rfft(piece * np.hanning(len(piece)))) + freqs = np.fft.rfftfreq(len(piece), 1 / ANALYSIS_RATE) + return float((spectrum * freqs).sum() / max(spectrum.sum(), 1e-12)) + + +# A track's separate sounds (each loud enough to matter): when they start, how long they last, how loud they peak +# against the track's loudest, their brightness and whether their pitch rises (above 1) or falls. +def track_measure(path): + signal = decode(path, ANALYSIS_RATE) + size = int(ANALYSIS_RATE * retro.FRAME) + if len(signal) < size * 4: + return {"path": str(path), "events": []} + frames = np.abs(signal[: len(signal) // size * size]).reshape(-1, size).max(axis=1) + above = frames > max(frames.max() * retro.THRESHOLD, np.percentile(frames, 10) * 5) + runs = [] + for i, isAbove in enumerate(above): + if isAbove and runs and i - runs[-1][1] <= retro.GAP / retro.FRAME: + runs[-1][1] = i + elif isAbove: + runs.append([i, i]) + loudest = float(np.abs(signal).max()) + events = [] + for first, last in runs[:16]: + piece = signal[first * size:(last + 1) * size] + peak = float(np.abs(piece).max()) + if peak < loudest * 0.3: + continue + half = len(piece) // 2 + events.append({"start": round(first * size / ANALYSIS_RATE, 3), "length": round(len(piece) / ANALYSIS_RATE, 3), + "peak": round(peak / max(loudest, 1e-9), 2), "centroid": round(centroid(piece[:int(0.4 * ANALYSIS_RATE)])), + "rise": round(centroid(piece[half:]) / max(centroid(piece[:half]), 1), 2) if half > 512 else 1.0}) + return {"path": str(path), "events": events} + + +# Measures every library track once (about a minute) and keeps the results. +def features_get(): + path = CACHE / "features.json" + if path.exists(): + return json.loads(path.read_text()) + print("Measuring the library tracks (once)...") + retro.track_find("US03-17-1") + with Pool() as pool: + tracks = pool.map(track_measure, list(retro.TRACKS.values()), chunksize=8) + for track in tracks: + stem = pathlib.Path(track["path"]).stem + track["id"], track["name"] = track_id(stem), stem + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(tracks)) + return tracks + + +def score(event, length, bright, pitch): + value = abs(math.log(max(event["length"], .005) / length)) + (1 - event["peak"]) * .5 + if bright and event["centroid"] > 0: + value += .5 * abs(math.log(event["centroid"] / bright)) + if pitch == "up": + value += max(0, 1.3 - event["rise"]) + if pitch == "down": + value += max(0, event["rise"] - .8) + return value + 5 * (event["centroid"] < 150) + + +def picks_get(name): + return retro.picks_get(name) + + +# The best few library sounds for a game sound besides its own, each from a different track, noting which other game +# sound already uses that track. Each is auditioned as the game's sound with its first layer swapped for it. +def alternatives_get(name, tracks, users): + pattern, length, bright, pitch = CANDIDATES[name] + picks = picks_get(name) + own = {pick.get("f") for pick in picks} | {pick.get("f") for _, suite in sfx_suites.SUITES.get(name, []) + for pick in (suite if isinstance(suite, list) else [suite])} + base = {key: value for key, value in picks[0].items() if key in ("n", "sp", "g", "r")} if "f" in picks[0] else {} + best = {} + for track in tracks: + if track["id"] in own or not re.search(pattern, track["name"]): + continue + for event in track["events"]: + value = score(event, length, bright, pitch) + if track["id"] not in best or value < best[track["id"]][0]: + best[track["id"]] = (value, track, event) + ranked = sorted(best.values(), key=lambda entry: entry[0])[:ALTERNATIVES] + return [{"label": track["name"], "pick": {"n": length, **base, "f": track["id"], "s": event["start"]}, + "detail": f"@ {event['start']:.2f}s · {event['length']:.2f}s · {event['centroid']} Hz" + + (f" · used by {', '.join(users[track['id']])}" if users.get(track["id"]) else "")} + for _, track, event in ranked] + + +def layer_describe(pick): + if "synth" in pick: + arguments = ", ".join(f"{key}={value}" for key, value in pick.get("a", {}).items()) + return {**pick, "track": f"synth {pick['synth']}({arguments})"} + return {**pick, "track": retro.track_find(pick["f"]).stem} + + +def suite_get(name): + candidates = [] + for label, pick in sfx_suites.SUITES.get(name, []): + layers = pick if isinstance(pick, list) else [pick] + candidates.append({"label": label, "pick": pick, "suite": True, + "detail": " + ".join(layer_describe(layer)["track"] for layer in layers)}) + return candidates + + +def sounds_get(tracks): + users = {} + for name in retro.PICKS: + for pick in picks_get(name): + if "f" in pick: + users.setdefault(pick["f"], []).append(name) + sounds = [] + for name in sorted(retro.PICKS, key=lambda name: (GROUPS.index(CONTEXT[name][0]), name)): + group, context = CONTEXT[name] + picks = picks_get(name) + kept = picks[0].get("keep") + sounds.append({"name": name, "group": group, "context": context, "kept": kept, + "pick": retro.PICKS[name], "layers": [] if kept else [layer_describe(pick) for pick in picks], + "sets": {key: (folder / f"{name}.ogg").exists() for key, folder in SETS.items()}, + "alternatives": suite_get(name) + alternatives_get(name, tracks, users)}) + return sounds + + +def notes_load(): + return json.loads(NOTES.read_text()) if NOTES.exists() else {} + + +LOCK = threading.Lock() + + +def cache_path(kind, key, suffix): + return CACHE / kind / f"{hashlib.sha1(key.encode()).hexdigest()[:16]}{suffix}" + + +def cached(kind, key, suffix, build): + path = cache_path(kind, key, suffix) + with LOCK: + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + build(path) + return path + + +# The whole source track as a small ogg, for listening around the cut. +def source_get(track): + def build(path): + subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(retro.track_find(track)), "-ac", "1", "-ar", "22050", + "-c:a", "libvorbis", "-q:a", "4", str(path)], check=True) + return cached("source", track, ".ogg", build) + + +# A track's waveform (peak per slice) and where a pick's cut lies in it, in seconds. +def wave_get(pick): + def build(path): + signal = retro.decode(retro.track_find(pick["f"])) + first, end, _ = retro.span_get(signal, pick) + low = signal[::retro.RATE // WAVE_RATE] + bins = np.array_split(np.abs(low), WAVE_BINS) + peaks = [round(float(chunk.max()) if len(chunk) else 0.0, 3) for chunk in bins] + path.write_text(json.dumps({"duration": len(signal) / retro.RATE, "peaks": peaks, + "cut": [first / retro.RATE, end / retro.RATE]})) + return cached("wave", json.dumps(pick, sort_keys=True), ".json", build) + + +# A game sound built from another pick, crunched as the game's would be, for auditioning alternatives. +def preview_key(name, pick): + return f"{name}|{json.dumps(pick if isinstance(pick, list) else [pick], sort_keys=True)}" + + +def preview_get(name, pick): + picks = pick if isinstance(pick, list) else [pick] + return cached("preview", preview_key(name, pick), ".ogg", lambda path: retro.render(name, picks, path)) + + +def preview_build(job): + name, pick, path = job + try: + retro.render(name, pick if isinstance(pick, list) else [pick], path) + return None + except Exception as error: + return f"{name}: {error}" + + +# Builds every candidate's preview up front, all at once, so they play straight away. +def previews_warm(sounds): + jobs = [(sound["name"], alt["pick"], cache_path("preview", preview_key(sound["name"], alt["pick"]), ".ogg")) + for sound in sounds for alt in sound["alternatives"]] + jobs = [job for job in jobs if not job[2].exists()] + if not jobs: + return + print(f"Building {len(jobs)} candidate previews...") + (CACHE / "preview").mkdir(parents=True, exist_ok=True) + with Pool() as pool: + for failure in pool.imap_unordered(preview_build, jobs): + if failure: + print(" failed:", failure) + + +class Handler(http.server.BaseHTTPRequestHandler): + sounds = [] + + def log_message(self, *args): + pass + + def reply(self, body, kind, status=200): + self.send_response(status) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def file_reply(self, path): + if not path.exists(): + return self.reply(b"missing", "text/plain", 404) + self.reply(path.read_bytes(), "application/json" if path.suffix == ".json" else "audio/ogg") + + def do_GET(self): + url = urllib.parse.urlparse(self.path) + query = {key: values[0] for key, values in urllib.parse.parse_qs(url.query).items()} + try: + if url.path == "/": + return self.reply(PAGE.encode(), "text/html; charset=utf-8") + if url.path == "/api/sounds": + previous = json.loads(PREVIOUS.read_text()) if PREVIOUS.exists() else {} + return self.reply(json.dumps({"sounds": self.sounds, "notes": notes_load(), "previous": previous, + "round": LAST_ROUND}).encode(), + "application/json") + if url.path.startswith("/audio/"): + _, _, kind, name = url.path.split("/") + if kind in SETS and re.fullmatch(r"\w+\.ogg", name): + return self.file_reply(SETS[kind] / name) + if url.path == "/source": + return self.file_reply(source_get(query["f"])) + if url.path == "/wave": + start = float(query["s"]) + return self.file_reply(wave_get({"f": query["f"], "s": None if start < 0 else start, "n": float(query["n"]), + "sp": float(query.get("sp", 1)), "x": query.get("x") == "1", + "j": query.get("j") == "1"})) + if url.path == "/preview" and query.get("name") in retro.PICKS: + return self.file_reply(preview_get(query["name"], json.loads(query["pick"]))) + except Exception as error: + return self.reply(str(error).encode(), "text/plain", 500) + self.reply(b"not found", "text/plain", 404) + + def do_POST(self): + if self.path != "/api/notes": + return self.reply(b"not found", "text/plain", 404) + entry = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + name = entry.pop("name") + with LOCK: + notes = notes_load() + kept = {key: value for key, value in entry.items() if value not in (None, "", [])} + if kept: + notes[name] = kept + else: + notes.pop(name, None) + NOTES.write_text(json.dumps(dict(sorted(notes.items())), indent=2) + "\n") + self.reply(b"{}", "application/json") + + +PAGE = r""" + +Sound review + +
+
+
Loading…
+ +""" + + +def main(): + numbers = [arg for arg in sys.argv[1:] if arg.isdigit()] + port = int(numbers[0]) if numbers else 8770 + missing = [name for name in retro.PICKS if name not in CONTEXT or name not in CANDIDATES] + assert not missing, f"no context or candidates for {missing}" + Handler.sounds = sounds_get(features_get()) + previews_warm(Handler.sounds) + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler) + url = f"http://127.0.0.1:{port}/" + print(f"Reviewing {len(Handler.sounds)} sounds at {url} (notes go to {NOTES.relative_to(ROOT)}). Ctrl+C to stop.") + if "--no-browser" not in sys.argv: + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/tools/sfx_review_notes.json b/tools/sfx_review_notes.json new file mode 100644 index 0000000..6bb229d --- /dev/null +++ b/tools/sfx_review_notes.json @@ -0,0 +1,338 @@ +{ + "beam": { + "verdict": "good" + }, + "blast": { + "verdict": "good" + }, + "boomerang": { + "verdict": "good" + }, + "boomerang_heavy": { + "verdict": "good" + }, + "bounce": { + "verdict": "good" + }, + "bow": { + "verdict": "good" + }, + "bow_heavy": { + "verdict": "good" + }, + "break": { + "verdict": "good" + }, + "cast": { + "verdict": "good" + }, + "charge": { + "verdict": "good" + }, + "crawl": { + "verdict": "good" + }, + "dash": { + "verdict": "good" + }, + "death": { + "choice": { + "label": "kick into stomach + squish", + "pick": [ + { + "f": "US03-34-1", + "s": 0.17, + "n": 0.45 + }, + { + "f": "4001_30_4", + "s": 0.8, + "n": 0.4, + "at": 0.05, + "g": 0.6 + } + ] + }, + "verdict": "replace" + }, + "detonate": { + "verdict": "good" + }, + "detonate_empty": { + "verdict": "good" + }, + "duck": { + "verdict": "good" + }, + "echo": { + "verdict": "good" + }, + "equip": { + "verdict": "good" + }, + "explosion": { + "verdict": "good" + }, + "fall": { + "verdict": "good" + }, + "finish": { + "verdict": "good" + }, + "fireball": { + "verdict": "good" + }, + "flail": { + "verdict": "good" + }, + "flail_heavy": { + "verdict": "good" + }, + "flail_land": { + "verdict": "good" + }, + "flame": { + "verdict": "good" + }, + "grapple": { + "choice": { + "label": "suppressed shot + chain", + "pick": [ + { + "f": "US04-42-1", + "s": 0.16, + "n": 0.12 + }, + { + "f": "4003_84", + "s": 15.46, + "n": 0.25, + "j": true, + "sp": 1.3, + "at": 0.03, + "g": 0.7 + } + ] + }, + "verdict": "replace" + }, + "grapple_heavy": { + "choice": { + "label": "slow swish + chain", + "pick": [ + { + "f": "4003_47_1", + "s": 0.395, + "n": 0.4, + "sp": 0.8 + }, + { + "f": "4003_84", + "s": 11.94, + "n": 0.35, + "j": true, + "g": 0.8 + } + ] + }, + "verdict": "replace" + }, + "hit_blunt": { + "verdict": "good" + }, + "hit_blunt_heavy": { + "verdict": "good" + }, + "hit_bullet": { + "verdict": "good" + }, + "hit_bullet_heavy": { + "verdict": "good" + }, + "hit_energy": { + "verdict": "good" + }, + "hit_energy_heavy": { + "verdict": "good" + }, + "hit_explosive": { + "verdict": "good" + }, + "hit_explosive_heavy": { + "verdict": "good" + }, + "hit_fire": { + "verdict": "good" + }, + "hit_fire_heavy": { + "verdict": "good" + }, + "hit_lash": { + "verdict": "good" + }, + "hit_lash_heavy": { + "verdict": "good" + }, + "hit_magic": { + "verdict": "good" + }, + "hit_magic_heavy": { + "verdict": "good" + }, + "hit_pierce": { + "verdict": "good" + }, + "hit_pierce_heavy": { + "verdict": "good" + }, + "hit_slash": { + "verdict": "good" + }, + "hit_slash_heavy": { + "verdict": "good" + }, + "impact_arrow": { + "verdict": "good" + }, + "impact_blaster": { + "verdict": "good" + }, + "impact_boomerang": { + "verdict": "good" + }, + "impact_bullet": { + "verdict": "good" + }, + "impact_mine": { + "verdict": "good" + }, + "jump": { + "verdict": "good" + }, + "land": { + "verdict": "good" + }, + "latch": { + "verdict": "good" + }, + "level_down": { + "verdict": "good" + }, + "level_up": { + "verdict": "good" + }, + "mine": { + "verdict": "good" + }, + "poke": { + "verdict": "good" + }, + "poke_heavy": { + "verdict": "good" + }, + "pound": { + "verdict": "good" + }, + "punch": { + "verdict": "good" + }, + "ready": { + "verdict": "good" + }, + "ricochet": { + "verdict": "good" + }, + "rocket": { + "verdict": "good" + }, + "rocket_heavy": { + "verdict": "good" + }, + "roll": { + "verdict": "good" + }, + "select": { + "verdict": "good" + }, + "shotgun": { + "verdict": "good" + }, + "shotgun_heavy": { + "choice": { + "label": "pump runs out, gentle tail fade", + "pick": [ + { + "f": "GUNS02_19_2", + "s": 0.012, + "n": 0.5, + "fd": 0.6 + }, + { + "f": "GUNS03_57_2", + "s": 0.06, + "n": 0.9, + "sp": 0.8, + "at": 0.42, + "g": 0.6, + "j": true, + "fd": 0.25 + } + ] + }, + "verdict": "replace" + }, + "slam": { + "verdict": "good" + }, + "slash": { + "verdict": "good" + }, + "slash_heavy": { + "verdict": "good" + }, + "step": { + "verdict": "good" + }, + "swap": { + "verdict": "good" + }, + "swing": { + "verdict": "good" + }, + "swing_heavy": { + "verdict": "good" + }, + "teleport": { + "verdict": "good" + }, + "teleport_warp": { + "verdict": "good" + }, + "unduck": { + "verdict": "good" + }, + "unequip": { + "verdict": "good" + }, + "uppercut": { + "verdict": "good" + }, + "uzi": { + "verdict": "good" + }, + "uzi_heavy": { + "verdict": "good" + }, + "whip": { + "verdict": "good" + }, + "whip_heavy": { + "verdict": "good" + }, + "wisp": { + "verdict": "good" + }, + "zap": { + "verdict": "good" + } +} diff --git a/tools/sfx_review_round1.json b/tools/sfx_review_round1.json new file mode 100644 index 0000000..3871a0b --- /dev/null +++ b/tools/sfx_review_round1.json @@ -0,0 +1,476 @@ +{ + "beam": { + "verdict": "good", + "note": "Game 1 good" + }, + "blast": { + "verdict": "replace", + "note": "Needs sci-fi pewpew synths" + }, + "boomerang": { + "verdict": "good" + }, + "boomerang_heavy": { + "verdict": "good" + }, + "bounce": { + "note": "Find a duller sound", + "verdict": "replace" + }, + "bow": { + "verdict": "replace", + "note": "\"CartoonTwang\" doesn't mean bow pulling; needs to be like, a \"unleashed stretched string\" sound" + }, + "bow_heavy": { + "verdict": "replace", + "note": "See previous, but a heavier version" + }, + "break": { + "verdict": "replace", + "note": "Find a bette crumbling sound" + }, + "cast": { + "verdict": "replace", + "note": "Needs another suite of examples; it should be like a fiery ember burn (they're wisps)" + }, + "charge": { + "verdict": "replace", + "note": "Tinkering with this; just another round of examples" + }, + "climb": { + "verdict": "replace", + "note": "I don't think this needs a bespoke sound effect; just reuse jump sound" + }, + "crawl": { + "verdict": "good", + "note": "Synth version" + }, + "dash": { + "choice": { + "f": "4003_47_4", + "s": 0.17, + "track": "Swish 4003_47_4" + }, + "verdict": "replace" + }, + "death": { + "verdict": "replace", + "note": "Another suite of examples to look at" + }, + "detonate": { + "verdict": "good", + "note": "synth good" + }, + "detonate_empty": { + "choice": { + "f": "GUNS03_24_3", + "s": 0.092, + "track": "HandGunFoley GUNS03_24_3" + }, + "verdict": "replace" + }, + "duck": { + "verdict": "good", + "note": "Synth version" + }, + "echo": { + "verdict": "replace", + "note": "Another suite of synth examples" + }, + "equip": { + "choice": { + "f": "GUNS03_39_1", + "s": 0.032, + "track": "MachineGunFoley GUNS03_39_1" + }, + "verdict": "replace" + }, + "explosion": { + "verdict": "good", + "note": "game is good" + }, + "fall": { + "verdict": "good", + "note": "Game ver" + }, + "finish": { + "note": "Demo 1 ", + "verdict": "good" + }, + "fireball": { + "verdict": "good", + "note": "demo 1 good" + }, + "flail": { + "verdict": "replace", + "note": "needs a whooshing sound, with metal/chain " + }, + "flail_heavy": { + "verdict": "replace", + "note": "Like a metal chain rustling noise needed" + }, + "flail_land": { + "verdict": "replace", + "note": "Metal chain + big thump" + }, + "flame": { + "verdict": "good", + "note": "Game 1 good" + }, + "grapple": { + "verdict": "replace", + "note": "A more metallic chain \"extending\" noise" + }, + "grapple_heavy": { + "verdict": "replace", + "note": "See regular grapple" + }, + "hit_blunt": { + "choice": { + "f": "4003_49_1", + "s": 0.407, + "track": "PunchBody 4003_49_1" + }, + "verdict": "replace" + }, + "hit_blunt_heavy": { + "choice": { + "f": "4003_48_6", + "s": 0.836, + "track": "PunchBody 4003_48_6" + }, + "verdict": "replace" + }, + "hit_bullet": { + "verdict": "good", + "note": "Game good" + }, + "hit_bullet_heavy": { + "choice": { + "f": "GUNS03_65_12", + "s": 0.054, + "track": "BulletImpact GUNS03_65_12" + }, + "verdict": "replace", + "note": "Sliiiightly cut this sound so the initial whoosh is missing; only the impact" + }, + "hit_energy": { + "choice": { + "f": "4002_27_5", + "s": 0.806, + "track": "CartoonGun 4002_27_5" + }, + "verdict": "replace" + }, + "hit_energy_heavy": { + "choice": { + "f": "4001_88_8", + "s": 0.832, + "track": "SpaceZap 4001_88_8" + }, + "verdict": "replace" + }, + "hit_explosive": { + "choice": { + "f": "4003_92_4", + "s": 2.438, + "track": "Explosion 4003_92_4" + }, + "verdict": "replace" + }, + "hit_explosive_heavy": { + "choice": { + "f": "4003_97_5", + "s": 1.209, + "track": "Explosion 4003_97_5" + }, + "verdict": "replace" + }, + "hit_fire": { + "note": "Use Explosion 4003_94_4 ", + "verdict": "replace" + }, + "hit_fire_heavy": { + "choice": { + "f": "GUNS02_46_1", + "s": 0.85, + "track": "GrenadeHand GUNS02_46_1" + }, + "verdict": "replace" + }, + "hit_lash": { + "choice": { + "f": "4003_52_5", + "s": 1.213, + "track": "SlapFace 4003_52_5" + }, + "verdict": "replace" + }, + "hit_lash_heavy": { + "choice": { + "f": "4003_52_6", + "s": 1.161, + "track": "SlapFace 4003_52_6" + }, + "verdict": "replace" + }, + "hit_magic": { + "verdict": "replace", + "note": "Something like a sparkling/crackling" + }, + "hit_magic_heavy": { + "verdict": "replace", + "note": "Sparkling/crackling" + }, + "hit_pierce": { + "choice": { + "f": "GUNS03_64_1", + "s": 0.036, + "track": "BulletImpact GUNS03_64_1" + }, + "verdict": "replace" + }, + "hit_pierce_heavy": { + "choice": { + "f": "GUNS02_24_3", + "s": 0.122, + "track": "Bayonnet GUNS02_24_3" + }, + "verdict": "good" + }, + "hit_slash": { + "choice": { + "f": "4003_56_4", + "s": 0.868, + "track": "StabHuman 4003_56_4" + }, + "verdict": "replace" + }, + "hit_slash_heavy": { + "choice": { + "f": "GUNS02_23_8", + "s": 0.14, + "track": "Bayonnet GUNS02_23_8" + }, + "verdict": "good" + }, + "impact_arrow": { + "choice": { + "f": "4003_63_3", + "s": 1.527, + "track": "DropWood 4003_63_3" + }, + "verdict": "replace" + }, + "impact_blaster": { + "choice": { + "f": "GUNS03_65_8", + "s": 0.016, + "track": "BulletImpact GUNS03_65_8" + }, + "verdict": "replace" + }, + "impact_boomerang": { + "verdict": "good", + "note": "game 1" + }, + "impact_bullet": { + "choice": { + "f": "GUNS03_64_1", + "s": 0.036, + "track": "BulletImpact GUNS03_64_1" + }, + "verdict": "replace" + }, + "impact_mine": { + "verdict": "good", + "note": "game" + }, + "jump": { + "verdict": "good", + "note": "Synth version" + }, + "land": { + "verdict": "good", + "note": "Synth" + }, + "latch": { + "verdict": "replace", + "note": "ChainRattle is almost it; more like a metallic thump" + }, + "level_down": { + "verdict": "replace", + "note": "Another suite of examples; maybe more synth options" + }, + "level_up": { + "verdict": "replace", + "note": "See level down" + }, + "mine": { + "verdict": "tweak", + "note": "Synth is almost good; i'd want a beeping. like a whoosh + \"beep beep!\"; perhaps something hodgepodged from samples " + }, + "orb": { + "verdict": "replace", + "note": "again, more fiery + whoosh" + }, + "poke": { + "verdict": "good", + "note": "game good" + }, + "poke_heavy": { + "verdict": "good", + "note": "game good" + }, + "pound": { + "verdict": "replace", + "note": "Find a big, dull, booming sound" + }, + "punch": { + "choice": { + "f": "4003_47_1", + "s": 0.395, + "track": "Swish 4003_47_1" + }, + "verdict": "replace" + }, + "ready": { + "verdict": "replace", + "note": "Another suite of examples" + }, + "ricochet": { + "choice": { + "f": "GUNS03_77_14", + "s": 9.393, + "track": "BulletRicochet GUNS03_77_14" + }, + "verdict": "replace" + }, + "rocket": { + "verdict": "good", + "note": "game good" + }, + "rocket_heavy": { + "choice": { + "f": "GUNS02_49_5", + "s": 19.681, + "track": "GrenadeLauncher GUNS02_49_5" + }, + "verdict": "replace" + }, + "roll": { + "verdict": "good", + "note": "Synth" + }, + "select": { + "choice": { + "f": "4003_64_4", + "s": 1.648, + "track": "DropCoin 4003_64_4" + }, + "verdict": "replace" + }, + "shotgun": { + "start": { + "1": 0.01 + }, + "verdict": "tweak", + "note": "i need like a double \"chk chk\" sound; surely there's something in that. the main fire is good; more fade on firing tho" + }, + "shotgun_heavy": { + "verdict": "tweak", + "note": "Needs a deep, double, slowed \"chk chk\"" + }, + "slam": { + "verdict": "replace", + "note": "Another suite to look at" + }, + "slash": { + "choice": { + "f": "4003_47_2", + "s": 0.792, + "track": "Swish 4003_47_2" + }, + "verdict": "replace" + }, + "slash_heavy": { + "verdict": "replace", + "note": "See regular slash; deeper sound" + }, + "step": { + "choice": { + "f": "4003_46_2", + "s": 0.796, + "track": "CartoonFootsteps 4003_46_2" + }, + "verdict": "replace" + }, + "swap": { + "verdict": "good", + "note": "Demo 1" + }, + "swing": { + "choice": { + "f": "4003_47_1", + "s": 0.395, + "track": "Swish 4003_47_1" + }, + "verdict": "replace" + }, + "swing_heavy": { + "verdict": "replace", + "note": "regular swing but deeper/slower?" + }, + "teleport": { + "verdict": "replace", + "note": "another suite to look at" + }, + "teleport_warp": { + "verdict": "replace", + "note": "Another suite." + }, + "unduck": { + "note": "synth", + "verdict": "good" + }, + "unequip": { + "choice": { + "f": "GUNS03_55_1", + "s": 0.489, + "track": "RifleFoley GUNS03_55_1" + }, + "verdict": "replace" + }, + "uppercut": { + "verdict": "replace", + "note": "synth gets the closest vibe; should be an intense, cool, whoosh sound" + }, + "uzi": { + "verdict": "good", + "note": "demo 1" + }, + "uzi_heavy": { + "choice": { + "f": "4002_86_3", + "s": 1.826, + "track": "GunMachineGun 4002_86_3" + }, + "verdict": "replace" + }, + "whip": { + "verdict": "good", + "note": "Game" + }, + "whip_blast": { + "verdict": "replace", + "note": "Remove this sound; redundant with whip heavy" + }, + "whip_heavy": { + "note": "Regular whip demo 1 should be this sound", + "verdict": "replace" + }, + "zap": { + "verdict": "replace", + "note": "Another suite of alternatives from libraries" + } +} diff --git a/tools/sfx_review_round2.json b/tools/sfx_review_round2.json new file mode 100644 index 0000000..70111d6 --- /dev/null +++ b/tools/sfx_review_round2.json @@ -0,0 +1,541 @@ +{ + "beam": { + "verdict": "good" + }, + "blast": { + "choice": { + "label": "pew: low with octave", + "pick": { + "synth": "pew", + "a": { + "start": 1200, + "end": 150, + "seconds": 0.18, + "duty": 0.125, + "octave": 0.5 + } + } + }, + "verdict": "replace" + }, + "boomerang": { + "verdict": "good" + }, + "boomerang_heavy": { + "verdict": "good" + }, + "bounce": { + "choice": { + "label": "Bodyfall", + "pick": { + "f": "4003_59_1", + "s": 0.54, + "n": 0.2 + } + }, + "verdict": "replace" + }, + "bow": { + "verdict": "good" + }, + "bow_heavy": { + "verdict": "good" + }, + "break": { + "choice": { + "label": "Universal small objects falling on cement", + "pick": { + "f": "US02-96-1", + "s": 0.59, + "n": 0.45 + } + }, + "verdict": "replace" + }, + "cast": { + "choice": { + "label": "synth embers", + "pick": { + "synth": "ember", + "a": {} + } + }, + "verdict": "replace" + }, + "charge": { + "choice": { + "label": "synth: warble rise", + "pick": { + "synth": "warp", + "a": { + "start": 200, + "end": 700, + "seconds": 0.8, + "wobble": 14, + "depth": 0.3 + } + } + }, + "verdict": "replace" + }, + "crawl": { + "verdict": "good" + }, + "dash": { + "verdict": "good" + }, + "death": { + "verdict": "replace", + "note": "Find fleshier, more gruesome examples. " + }, + "detonate": { + "verdict": "good" + }, + "detonate_empty": { + "verdict": "good" + }, + "duck": { + "verdict": "good" + }, + "echo": { + "choice": { + "label": "synth: shimmer, high and fast", + "pick": { + "synth": "shimmer", + "a": { + "frequency": 2000, + "rate": 30, + "duty": 0.25 + } + } + }, + "verdict": "replace" + }, + "equip": { + "verdict": "good" + }, + "explosion": { + "verdict": "good" + }, + "fall": { + "verdict": "good" + }, + "finish": { + "verdict": "good" + }, + "fireball": { + "verdict": "good" + }, + "flail": { + "choice": { + "label": "swish + chain rattle", + "pick": [ + { + "f": "4003_47_4", + "s": 0.17, + "n": 0.3 + }, + { + "f": "4003_84", + "s": 15.46, + "n": 0.3, + "j": true, + "g": 0.6 + } + ] + }, + "verdict": "replace" + }, + "flail_heavy": { + "choice": { + "label": "slow swish + chain rattle", + "pick": [ + { + "f": "4003_47_1", + "s": 0.395, + "n": 0.45, + "sp": 0.7 + }, + { + "f": "4003_84", + "s": 15.46, + "n": 0.45, + "j": true, + "g": 0.7 + } + ] + }, + "verdict": "replace" + }, + "flail_land": { + "choice": { + "label": "US03-37-2 METAL, BANG - LARGE METAL WRENCH BOUNCES OFF STEEL AND FALLS", + "pick": { + "n": 0.3, + "f": "US03-37-2", + "s": 0.782 + } + }, + "verdict": "replace" + }, + "flame": { + "verdict": "good" + }, + "grapple": { + "choice": { + "label": "chain rattle, faster", + "pick": { + "f": "4003_84", + "s": 15.46, + "n": 0.25, + "j": true, + "sp": 1.3 + } + }, + "verdict": "tweak", + "note": "Needs a firing sound + this chain rattling" + }, + "grapple_heavy": { + "choice": { + "label": "chain rattle (earlier)", + "pick": { + "f": "4003_84", + "s": 11.94, + "n": 0.35, + "j": true + } + }, + "verdict": "replace", + "note": "Plus a whoosh sound" + }, + "hit_blunt": { + "verdict": "good" + }, + "hit_blunt_heavy": { + "verdict": "good" + }, + "hit_bullet": { + "verdict": "good" + }, + "hit_bullet_heavy": { + "verdict": "good" + }, + "hit_energy": { + "verdict": "good" + }, + "hit_energy_heavy": { + "verdict": "good" + }, + "hit_explosive": { + "verdict": "good" + }, + "hit_explosive_heavy": { + "verdict": "good" + }, + "hit_fire": { + "verdict": "good" + }, + "hit_fire_heavy": { + "verdict": "good" + }, + "hit_lash": { + "verdict": "good" + }, + "hit_lash_heavy": { + "verdict": "good" + }, + "hit_magic": { + "choice": { + "label": "fire puff + synth sparkle", + "pick": [ + { + "f": "US03-17-1", + "s": 0.17, + "n": 0.15, + "sp": 1.3, + "r": 16000 + }, + { + "synth": "sparkle", + "a": {}, + "g": 0.7 + } + ] + }, + "verdict": "replace" + }, + "hit_magic_heavy": { + "choice": { + "label": "SpaceZap 4001_88_9", + "pick": { + "n": 0.3, + "r": 16000, + "f": "4001_88_9", + "s": 1.026 + } + }, + "verdict": "replace" + }, + "hit_pierce": { + "verdict": "good" + }, + "hit_pierce_heavy": { + "verdict": "good" + }, + "hit_slash": { + "verdict": "good" + }, + "hit_slash_heavy": { + "verdict": "good" + }, + "impact_arrow": { + "verdict": "good" + }, + "impact_blaster": { + "verdict": "good" + }, + "impact_boomerang": { + "verdict": "good" + }, + "impact_bullet": { + "verdict": "good" + }, + "impact_mine": { + "verdict": "good" + }, + "jump": { + "verdict": "good" + }, + "land": { + "verdict": "good" + }, + "latch": { + "choice": { + "label": "Universal metal target hit", + "pick": { + "f": "US02-21-3", + "s": 0.16, + "n": 0.25 + } + }, + "verdict": "replace" + }, + "level_down": { + "choice": { + "label": "synth: minor down, thin", + "pick": { + "synth": "arp", + "a": { + "notes": [ + 784, + 622, + 523, + 392 + ], + "seconds": 0.35, + "duty": 0.25 + } + } + }, + "verdict": "replace" + }, + "level_up": { + "choice": { + "label": "synth: five notes up, thin", + "pick": { + "synth": "arp", + "a": { + "notes": [ + 392, + 523, + 659, + 784, + 1047 + ], + "seconds": 0.35, + "duty": 0.25 + } + } + }, + "verdict": "replace" + }, + "mine": { + "choice": { + "label": "swish + beep beep", + "pick": [ + { + "f": "4003_47_3", + "s": 0.18, + "n": 0.15 + }, + { + "synth": "beeps", + "a": {}, + "at": 0.12 + } + ] + }, + "verdict": "good" + }, + "orb": { + "verdict": "good" + }, + "poke": { + "verdict": "good" + }, + "poke_heavy": { + "verdict": "good" + }, + "pound": { + "choice": { + "label": "synth thud", + "pick": { + "synth": "thud", + "a": { + "start": 120, + "end": 35, + "seconds": 0.5, + "body": 0.8 + }, + "r": 8000 + } + }, + "verdict": "replace" + }, + "punch": { + "verdict": "good" + }, + "ready": { + "choice": { + "label": "Universal beer mugs clink", + "pick": { + "f": "US03-02-1", + "s": 0.16, + "n": 0.4 + } + }, + "verdict": "replace" + }, + "ricochet": { + "verdict": "good" + }, + "rocket": { + "verdict": "good" + }, + "rocket_heavy": { + "verdict": "good" + }, + "roll": { + "verdict": "good" + }, + "select": { + "verdict": "good" + }, + "shotgun": { + "verdict": "good" + }, + "shotgun_heavy": { + "verdict": "tweak", + "note": "Almost good; just need to have the chk chk fade out better" + }, + "slam": { + "choice": { + "label": "Universal loud metal clunk, slowed", + "pick": { + "f": "US03-39-2", + "s": 0.16, + "n": 0.6, + "sp": 0.7 + } + }, + "verdict": "replace" + }, + "slash": { + "verdict": "good" + }, + "slash_heavy": { + "verdict": "good" + }, + "step": { + "verdict": "good" + }, + "swap": { + "verdict": "good" + }, + "swing": { + "verdict": "good" + }, + "swing_heavy": { + "verdict": "good" + }, + "teleport": { + "choice": { + "label": "Universal descending space whines", + "pick": { + "f": "US03-68-1", + "s": 0.8, + "n": 0.3 + } + }, + "verdict": "replace" + }, + "teleport_warp": { + "choice": { + "label": "Universal ascending space whine, much faster", + "pick": { + "f": "US03-68-3", + "s": 0.32, + "n": 0.38, + "sp": 3 + } + }, + "verdict": "replace" + }, + "unduck": { + "verdict": "good" + }, + "unequip": { + "verdict": "good" + }, + "uppercut": { + "choice": { + "label": "synth whoosh, long and rising", + "pick": { + "synth": "whoosh", + "a": { + "seconds": 0.5, + "low": 0.02, + "high": 0.8, + "roar": 0.8, + "roarStart": 120, + "roarEnd": 480 + } + } + }, + "verdict": "replace" + }, + "uzi": { + "verdict": "good" + }, + "uzi_heavy": { + "verdict": "good" + }, + "whip": { + "verdict": "good" + }, + "whip_heavy": { + "verdict": "good" + }, + "zap": { + "choice": { + "label": "Universal small transformer arcs", + "pick": { + "f": "US03-12-1", + "s": 0.48, + "n": 0.2, + "j": true + } + }, + "verdict": "good" + } +} diff --git a/tools/sfx_suites.py b/tools/sfx_suites.py new file mode 100644 index 0000000..d1fac8e --- /dev/null +++ b/tools/sfx_suites.py @@ -0,0 +1,214 @@ +# Candidates for the sounds the review is still choosing, and the chip-style synth recipes some of them use. +# Each candidate is a pick in tools/sfx_build.py's form (a layer, or a list of layers), so it's auditioned crunched +# exactly as the game's sound would be, and choosing it in the review means copying it into PICKS. A layer is either +# cut from a library track ({"f": ..., "s": ...}) or synthesized here ({"synth": recipe, "a": {arguments}}). + +import math + +import numpy as np + +RATE = 44100 + + +def time_axis(seconds): + return np.arange(int(RATE * seconds)) / RATE + + +def envelope(t, attack, power): + rise = np.clip(t / max(attack, 1e-4), 0, 1) + return rise * (1 - t / max(t[-1], 1e-9)) ** power + + +def glide(t, start, end): + # Exponential, so it sweeps evenly by ear. + return start * (end / start) ** (t / max(t[-1], 1e-9)) + + +def phase(frequency): + return 2 * math.pi * np.cumsum(frequency) / RATE + + +def pulse(angle, duty=0.5): + return np.where((angle / (2 * math.pi)) % 1 < duty, 1.0, -1.0) + + +def noise(count, seed): + return np.random.default_rng(seed).uniform(-1, 1, count) + + +def lowpass(signal, amount): + amounts = np.broadcast_to(amount, signal.shape) + out = np.zeros_like(signal) + value = 0.0 + for i, sample in enumerate(signal): + value += (sample - value) * amounts[i] + out[i] = value + return out + + +def at(signal, part, seconds): + start = int(seconds * RATE) + end = min(len(signal), start + len(part)) + signal[start:end] += part[:end - start] + return signal + + +# A pulse-wave laser: a fast falling sweep, optionally with an octave above and a wobble. +def pew(start=1600, end=200, seconds=0.14, duty=0.5, octave=0.0, vibrato=0.0): + t = time_axis(seconds) + frequency = glide(t, start, end) * (1 + 0.08 * np.sin(2 * math.pi * vibrato * t)) + tone = pulse(phase(frequency), duty) + octave * pulse(phase(frequency * 2), duty) + return 0.5 * tone * envelope(t, 0.002, 1.3) + + +# Notes one after another (Hz), on a pulse wave. +def arp(notes=(523, 659, 784, 1047), seconds=0.3, duty=0.5): + t = time_axis(seconds) + step = len(t) / len(notes) + frequency = np.array(notes, dtype=float)[np.minimum((np.arange(len(t)) / step).astype(int), len(notes) - 1)] + return 0.45 * pulse(phase(frequency), duty) * envelope(t, 0.002, 0.8) + + +def slide(start=400, end=1200, seconds=0.3, duty=0.5, vibrato=0.0): + t = time_axis(seconds) + frequency = glide(t, start, end) * (1 + 0.06 * np.sin(2 * math.pi * vibrato * t)) + return 0.45 * pulse(phase(frequency), duty) * envelope(t, 0.004, 1) + + +def beeps(frequency=1800, count=2, length=0.045, gap=0.07, duty=0.5): + t = time_axis(length) + beep = 0.5 * pulse(2 * math.pi * frequency * t, duty) * envelope(t, 0.001, 0.4) + signal = np.zeros(int(RATE * (gap * (count - 1) + length)) + 1) + for i in range(count): + at(signal, beep, i * gap) + return signal + + +# Short blips repeating and fading, like an echo. +def echo_blips(frequency=1400, repeats=3, gap=0.07, duty=0.25): + t = time_axis(0.05) + blip = pulse(phase(glide(t, frequency, frequency * 0.7)), duty) * envelope(t, 0.001, 1.5) + signal = np.zeros(int(RATE * (gap * repeats + 0.05)) + 1) + for i in range(repeats): + at(signal, 0.5 * blip * 0.55 ** i, i * gap) + return signal + + +def shimmer(frequency=1400, rate=22, seconds=0.28, duty=0.5): + t = time_axis(seconds) + tone = pulse(2 * math.pi * frequency * t, duty) * (0.6 + 0.4 * np.sin(2 * math.pi * rate * t)) + return 0.4 * tone * envelope(t, 0.005, 2) + + +# A ring-modulated chirp: metallic, bell-ish and odd. +def ring_chirp(start=1800, end=900, seconds=0.22, ring=330): + t = time_axis(seconds) + return 0.6 * np.sin(phase(glide(t, start, end))) * np.sin(2 * math.pi * ring * t) * envelope(t, 0.002, 1.5) + + +# Rising, wobbling tone: a warp gathering or a charge building. +def warp(start=300, end=1500, seconds=0.38, wobble=24, duty=0.5, depth=0.5): + t = time_axis(seconds) + tone = pulse(phase(glide(t, start, end)), duty) * (1 + depth * np.sin(2 * math.pi * wobble * t)) + return 0.3 * tone * np.sin(math.pi * t / t[-1]) ** 0.5 + + +def charge(start=180, end=520, seconds=0.8, duty=0.5, shimmer=12, rumble=0.0): + t = time_axis(seconds) + tone = pulse(phase(glide(t, start, end)), duty) * (0.75 + 0.25 * np.sin(2 * math.pi * shimmer * t)) + low = rumble * lowpass(noise(len(t), 3), 0.03) * 4 + return 0.3 * (tone + low) * np.clip(t / 0.12, 0, 1) + + +# Noise swept from dark to bright and back, with a rising roar under it: a big whoosh. +def whoosh(seconds=0.45, low=0.03, high=0.5, seed=62, roar=0.6, roarStart=90, roarEnd=260, drive=1.8): + t = time_axis(seconds) + sweep = lowpass(noise(len(t), seed), np.linspace(low, high, len(t))) * np.sin(math.pi * t / t[-1]) ** 0.6 + tone = np.sin(phase(glide(t, roarStart, roarEnd))) * envelope(t, 0.01, 2.5) + return 0.95 * np.tanh(drive * (1.4 * sweep + roar * tone)) + + +# Embers: a soft hiss swelling up with crackling pops scattered through it. +def ember(seconds=0.4, seed=7, density=60, hiss=0.5, swell=0.3): + t = time_axis(seconds) + rng = np.random.default_rng(seed) + body = hiss * lowpass(noise(len(t), seed), 0.12) * np.clip(t / max(swell * seconds, 1e-3), 0, 1) + pops = np.zeros(len(t)) + for start in rng.uniform(0, seconds * 0.9, int(density * seconds)): + size = int(rng.uniform(0.001, 0.004) * RATE) + at(pops, rng.uniform(0.3, 1) * noise(size, int(start * 1e6)) * np.linspace(1, 0, size), start) + return 0.9 * (body + pops) * envelope(t, 0.02, 0.8) + + +# Sparkles: quick high pings at random pitches over a crackle, with an optional thump for weight. +def sparkle(seconds=0.15, seed=11, density=90, low=2000, high=5000, crackle=0.5, thump=0.0): + t = time_axis(seconds) + rng = np.random.default_rng(seed) + signal = np.zeros(len(t)) + for start in rng.uniform(0, seconds * 0.8, max(3, int(density * seconds))): + ping = time_axis(rng.uniform(0.015, 0.04)) + at(signal, 0.5 * np.sin(2 * math.pi * rng.uniform(low, high) * ping) * envelope(ping, 0.0005, 2), start) + signal += crackle * np.where(rng.random(len(t)) < 0.02, rng.uniform(-1, 1, len(t)), 0) + signal += thump * np.sin(phase(glide(t, 180, 60))) * envelope(t, 0.001, 3) + return signal * envelope(t, 0.001, 1.2) + + +# A dull, low thud: a falling sine with a muffled noise body. +def thud(start=140, end=45, seconds=0.35, body=0.6, seed=9): + t = time_axis(seconds) + boom = np.sin(phase(glide(t, start, end))) * envelope(t, 0.002, 2) + return 0.8 * np.tanh(1.5 * (boom + body * lowpass(noise(len(t), seed), 0.04) * 4 * envelope(t, 0.002, 3))) + + +# An arrival pop: a falling thump and a burst, with a sparkle over it. +def pop(start=500, end=90, seconds=0.3, glitter=0.4): + t = time_axis(seconds) + body = np.sin(phase(glide(t, start, end))) * envelope(t, 0.001, 4) + 0.7 * lowpass(noise(len(t), 91), 0.25) * envelope(t, 0.0005, 6) + shine = glitter * (np.sin(2 * math.pi * 2200 * t) + 0.5 * np.sin(2 * math.pi * 3300 * t)) * envelope(t, 0.001, 7) + return 0.8 * np.tanh(body + shine) + + +def bell(frequency=1320, seconds=0.45): + t = time_axis(seconds) + tone = sum(np.sin(2 * math.pi * frequency * ratio * t) * weight for ratio, weight in [(1, 1), (1.5, 0.6), (2, 0.25), (2.76, 0.15)]) + return 0.35 * tone * envelope(t, 0.002, 3) + + +# A plucked string (Karplus-Strong): a burst of noise looping through a delay the length of one period, softened each +# pass, so it rings at the string's pitch and dulls as it dies. +def pluck(frequency=180, seconds=0.3, damping=0.996): + count = int(RATE * seconds) + period = max(2, int(RATE / frequency)) + buffer = list(noise(period, 21)) + out = np.zeros(count) + for i in range(count): + first = buffer.pop(0) + out[i] = first + buffer.append(damping * 0.5 * (first + buffer[0])) + return 0.9 * out + + +# The first synthesized mine: a clunk with a short beep after it. +def mine_clunk(): + t = time_axis(0.12) + clunk = np.sin(phase(glide(t, 220, 120))) * envelope(t, 0.001, 8) + lowpass(noise(len(t), 32), 0.5) * envelope(t, 0.001, 14) + return 0.6 * clunk + + +SYNTHS = {function.__name__: function for function in + [pew, arp, slide, beeps, echo_blips, shimmer, ring_chirp, warp, charge, whoosh, ember, sparkle, thud, pop, + bell, pluck, mine_clunk]} + + +def L(f, s, n, **options): + return {"f": f, "s": s, "n": n, **options} + + +def S(recipe, **arguments): + options = {key: arguments.pop(key) for key in ["g", "at", "r"] if key in arguments} + return {"synth": recipe, "a": arguments, **options} + + +# For each sound still being chosen: candidates as (label, pick), the first being what the game uses meanwhile. All +# chosen for now; a new review round puts the open sounds' candidates here. +SUITES = {} diff --git a/tools/web_pre.js b/tools/web_pre.js new file mode 100644 index 0000000..707d82b --- /dev/null +++ b/tools/web_pre.js @@ -0,0 +1,9 @@ +// The browser build: keeps the prefs folder (/libsdl, where SDL puts it on the web) in IndexedDB, loading it before +// the game starts so saved settings are there when it reads them. The game writes it back after every save. +Module.preRun = Module.preRun || []; +Module.preRun.push(() => { + FS.mkdir("/libsdl"); + FS.mount(IDBFS, {}, "/libsdl"); + addRunDependency("prefs"); + FS.syncfs(true, () => removeRunDependency("prefs")); +}); diff --git a/tools/web_shell.html b/tools/web_shell.html new file mode 100644 index 0000000..5a18915 --- /dev/null +++ b/tools/web_shell.html @@ -0,0 +1,29 @@ + + + + + +Smack a Brother + + + +
Loading...
+ + +{{{ SCRIPT }}} + +