From 2007fb7edc699b020c36038221efc4d4749b05d3 Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 01:30:33 -0500 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 5.5 --- .clang-format | 32 + .gitignore | 4 + .gitmodules | 16 + CLAUDE.md | 29 + CMakeLists.txt | 86 + external/datachannel-wasm | 1 + external/libdatachannel | 1 + external/sdl | 1 + external/simdjson | 1 + external/stb | 1 + resources/font/big.fnt | 103 + resources/font/big.png | Bin 0 -> 2186 bytes resources/font/huge.fnt | 103 + resources/font/huge.png | Bin 0 -> 2823 bytes resources/font/pixel_purl.ttf | Bin 0 -> 11388 bytes resources/font/small.fnt | 103 + resources/font/small.png | Bin 0 -> 1673 bytes resources/font/tiny.fnt | 102 + resources/font/tiny.png | Bin 0 -> 798 bytes resources/gfx/dummy.anm2 | 29 + resources/gfx/dummy.png | Bin 0 -> 279 bytes resources/gfx/effects.anm2 | 62 + resources/gfx/effects.png | Bin 0 -> 2279 bytes resources/gfx/modes.anm2 | 71 + resources/gfx/modes.png | Bin 0 -> 347 bytes resources/gfx/player.anm2 | 89 + resources/gfx/player.png | Bin 0 -> 9843 bytes resources/gfx/tiles.anm2 | 120 ++ resources/gfx/tiles.png | Bin 0 -> 613 bytes resources/gfx/trinkets.anm2 | 241 +++ resources/gfx/trinkets.png | Bin 0 -> 12041 bytes resources/gfx/trinkets_small.anm2 | 567 +++++ resources/gfx/trinkets_small.png | Bin 0 -> 1976 bytes resources/gfx/trinkets_tiny.anm2 | 567 +++++ resources/gfx/trinkets_tiny.png | Bin 0 -> 697 bytes resources/gfx/ui.anm2 | 53 + resources/gfx/ui.png | Bin 0 -> 8972 bytes resources/gfx/weapon_icons.anm2 | 189 ++ resources/gfx/weapon_icons.png | Bin 0 -> 10989 bytes resources/gfx/weapon_icons_small.anm2 | 419 ++++ resources/gfx/weapon_icons_small.png | Bin 0 -> 1325 bytes resources/gfx/weapon_icons_tiny.anm2 | 419 ++++ resources/gfx/weapon_icons_tiny.png | Bin 0 -> 504 bytes resources/gfx/weapons.anm2 | 55 + resources/gfx/weapons.png | Bin 0 -> 11080 bytes resources/sfx/SOURCES.md | 105 + resources/sfx/beam.ogg | Bin 0 -> 7144 bytes resources/sfx/blast.ogg | Bin 0 -> 5269 bytes resources/sfx/boomerang.ogg | Bin 0 -> 6129 bytes resources/sfx/boomerang_heavy.ogg | Bin 0 -> 7630 bytes resources/sfx/bounce.ogg | Bin 0 -> 5143 bytes resources/sfx/bow.ogg | Bin 0 -> 5083 bytes resources/sfx/bow_heavy.ogg | Bin 0 -> 5396 bytes resources/sfx/break.ogg | Bin 0 -> 7814 bytes resources/sfx/cast.ogg | Bin 0 -> 8380 bytes resources/sfx/charge.ogg | Bin 0 -> 9623 bytes resources/sfx/chat.ogg | Bin 0 -> 4904 bytes resources/sfx/crawl.ogg | Bin 0 -> 4498 bytes resources/sfx/dash.ogg | Bin 0 -> 5597 bytes resources/sfx/death.ogg | Bin 0 -> 7220 bytes resources/sfx/detonate.ogg | Bin 0 -> 5280 bytes resources/sfx/detonate_empty.ogg | Bin 0 -> 4698 bytes resources/sfx/duck.ogg | Bin 0 -> 5125 bytes resources/sfx/echo.ogg | Bin 0 -> 6148 bytes resources/sfx/equip.ogg | Bin 0 -> 6735 bytes resources/sfx/explosion.ogg | Bin 0 -> 10314 bytes resources/sfx/fall.ogg | Bin 0 -> 8414 bytes resources/sfx/finish.ogg | Bin 0 -> 8859 bytes resources/sfx/fireball.ogg | Bin 0 -> 6330 bytes resources/sfx/flail.ogg | Bin 0 -> 6365 bytes resources/sfx/flail_heavy.ogg | Bin 0 -> 7375 bytes resources/sfx/flail_land.ogg | Bin 0 -> 6192 bytes resources/sfx/flame.ogg | Bin 0 -> 5153 bytes resources/sfx/grapple.ogg | Bin 0 -> 6321 bytes resources/sfx/grapple_heavy.ogg | Bin 0 -> 6523 bytes resources/sfx/hit_blunt.ogg | Bin 0 -> 5031 bytes resources/sfx/hit_blunt_heavy.ogg | Bin 0 -> 5805 bytes resources/sfx/hit_bullet.ogg | Bin 0 -> 4562 bytes resources/sfx/hit_bullet_heavy.ogg | Bin 0 -> 6233 bytes resources/sfx/hit_energy.ogg | Bin 0 -> 4994 bytes resources/sfx/hit_energy_heavy.ogg | Bin 0 -> 6176 bytes resources/sfx/hit_explosive.ogg | Bin 0 -> 6294 bytes resources/sfx/hit_explosive_heavy.ogg | Bin 0 -> 7409 bytes resources/sfx/hit_fire.ogg | Bin 0 -> 6175 bytes resources/sfx/hit_fire_heavy.ogg | Bin 0 -> 7322 bytes resources/sfx/hit_lash.ogg | Bin 0 -> 4854 bytes resources/sfx/hit_lash_heavy.ogg | Bin 0 -> 5558 bytes resources/sfx/hit_magic.ogg | Bin 0 -> 5265 bytes resources/sfx/hit_magic_heavy.ogg | Bin 0 -> 6133 bytes resources/sfx/hit_pierce.ogg | Bin 0 -> 5126 bytes resources/sfx/hit_pierce_heavy.ogg | Bin 0 -> 6038 bytes resources/sfx/hit_slash.ogg | Bin 0 -> 5081 bytes resources/sfx/hit_slash_heavy.ogg | Bin 0 -> 6150 bytes resources/sfx/impact_arrow.ogg | Bin 0 -> 5702 bytes resources/sfx/impact_blaster.ogg | Bin 0 -> 4964 bytes resources/sfx/impact_boomerang.ogg | Bin 0 -> 5756 bytes resources/sfx/impact_bullet.ogg | Bin 0 -> 4810 bytes resources/sfx/impact_mine.ogg | Bin 0 -> 5191 bytes resources/sfx/jump.ogg | Bin 0 -> 5284 bytes resources/sfx/land.ogg | Bin 0 -> 4792 bytes resources/sfx/latch.ogg | Bin 0 -> 6363 bytes resources/sfx/level_down.ogg | Bin 0 -> 6701 bytes resources/sfx/level_up.ogg | Bin 0 -> 6677 bytes resources/sfx/mine.ogg | Bin 0 -> 6132 bytes resources/sfx/poke.ogg | Bin 0 -> 5384 bytes resources/sfx/poke_heavy.ogg | Bin 0 -> 6410 bytes resources/sfx/pound.ogg | Bin 0 -> 7086 bytes resources/sfx/punch.ogg | Bin 0 -> 4808 bytes resources/sfx/ready.ogg | Bin 0 -> 5469 bytes resources/sfx/ricochet.ogg | Bin 0 -> 5981 bytes resources/sfx/rocket.ogg | Bin 0 -> 7216 bytes resources/sfx/rocket_heavy.ogg | Bin 0 -> 9638 bytes resources/sfx/roll.ogg | Bin 0 -> 4521 bytes resources/sfx/select.ogg | Bin 0 -> 4699 bytes resources/sfx/shotgun.ogg | Bin 0 -> 9911 bytes resources/sfx/shotgun_heavy.ogg | Bin 0 -> 15015 bytes resources/sfx/slam.ogg | Bin 0 -> 8035 bytes resources/sfx/slash.ogg | Bin 0 -> 5434 bytes resources/sfx/slash_heavy.ogg | Bin 0 -> 5962 bytes resources/sfx/step.ogg | Bin 0 -> 4657 bytes resources/sfx/swap.ogg | Bin 0 -> 4363 bytes resources/sfx/swing.ogg | Bin 0 -> 5350 bytes resources/sfx/swing_heavy.ogg | Bin 0 -> 5952 bytes resources/sfx/teleport.ogg | Bin 0 -> 5777 bytes resources/sfx/teleport_warp.ogg | Bin 0 -> 6714 bytes resources/sfx/unduck.ogg | Bin 0 -> 4932 bytes resources/sfx/unequip.ogg | Bin 0 -> 6261 bytes resources/sfx/uppercut.ogg | Bin 0 -> 7780 bytes resources/sfx/uzi.ogg | Bin 0 -> 4160 bytes resources/sfx/uzi_heavy.ogg | Bin 0 -> 6569 bytes resources/sfx/whip.ogg | Bin 0 -> 5925 bytes resources/sfx/whip_heavy.ogg | Bin 0 -> 5301 bytes resources/sfx/wisp.ogg | Bin 0 -> 5822 bytes resources/sfx/zap.ogg | Bin 0 -> 5483 bytes server/CMakeLists.txt | 25 + server/server.cpp | 240 +++ src/anm2.cpp | 327 +++ src/anm2.h | 80 + src/audio.cpp | 170 ++ src/audio.h | 128 ++ src/balance.cpp | 167 ++ src/balance.h | 15 + src/bot.cpp | 966 +++++++++ src/bot.h | 147 ++ src/chat.cpp | 384 ++++ src/chat.h | 59 + src/common.h | 217 ++ src/dmath.h | 232 +++ src/effects.cpp | 256 +++ src/effects.h | 102 + src/fighter.cpp | 1015 +++++++++ src/fighter.h | 173 ++ src/game.h | 103 + src/input.cpp | 530 +++++ src/input.h | 198 ++ src/main.cpp | 304 +++ src/match.cpp | 761 +++++++ src/match.h | 229 ++ src/menu.cpp | 1120 ++++++++++ src/menu.h | 126 ++ src/net.cpp | 493 +++++ src/net.h | 84 + src/protocol.h | 81 + src/renderer.cpp | 456 ++++ src/renderer.h | 75 + src/rollback.cpp | 123 ++ src/rollback.h | 46 + src/session.cpp | 1417 +++++++++++++ src/session.h | 224 ++ src/settings.cpp | 691 ++++++ src/settings.h | 86 + src/sprites.cpp | 29 + src/sprites.h | 32 + src/state.cpp | 162 ++ src/state.h | 13 + src/text.cpp | 156 ++ src/text.h | 56 + src/trinket.h | 164 ++ src/ui.cpp | 2777 +++++++++++++++++++++++++ src/ui.h | 303 +++ src/weapon.cpp | 2513 ++++++++++++++++++++++ src/weapon.h | 902 ++++++++ src/world.cpp | 723 +++++++ src/world.h | 121 ++ tools/assets_generate.py | 777 +++++++ tools/balance_runs.py | 67 + tools/determinism_check.sh | 84 + tools/fonts_export.py | 172 ++ tools/icons_resize.py | 133 ++ tools/node_pre.js | 5 + tools/sfx_build.py | 366 ++++ tools/sfx_library_demo.py | 260 +++ tools/sfx_loudness.json | 90 + tools/sfx_review.py | 779 +++++++ tools/sfx_review_notes.json | 338 +++ tools/sfx_review_round1.json | 476 +++++ tools/sfx_review_round2.json | 541 +++++ tools/sfx_suites.py | 214 ++ tools/web_pre.js | 9 + tools/web_shell.html | 29 + 200 files changed, 27710 insertions(+) create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 100644 CMakeLists.txt create mode 160000 external/datachannel-wasm create mode 160000 external/libdatachannel create mode 160000 external/sdl create mode 160000 external/simdjson create mode 160000 external/stb create mode 100644 resources/font/big.fnt create mode 100644 resources/font/big.png create mode 100644 resources/font/huge.fnt create mode 100644 resources/font/huge.png create mode 100644 resources/font/pixel_purl.ttf create mode 100644 resources/font/small.fnt create mode 100644 resources/font/small.png create mode 100644 resources/font/tiny.fnt create mode 100644 resources/font/tiny.png create mode 100644 resources/gfx/dummy.anm2 create mode 100644 resources/gfx/dummy.png create mode 100644 resources/gfx/effects.anm2 create mode 100644 resources/gfx/effects.png create mode 100644 resources/gfx/modes.anm2 create mode 100644 resources/gfx/modes.png create mode 100644 resources/gfx/player.anm2 create mode 100644 resources/gfx/player.png create mode 100644 resources/gfx/tiles.anm2 create mode 100644 resources/gfx/tiles.png create mode 100644 resources/gfx/trinkets.anm2 create mode 100644 resources/gfx/trinkets.png create mode 100644 resources/gfx/trinkets_small.anm2 create mode 100644 resources/gfx/trinkets_small.png create mode 100644 resources/gfx/trinkets_tiny.anm2 create mode 100644 resources/gfx/trinkets_tiny.png create mode 100644 resources/gfx/ui.anm2 create mode 100644 resources/gfx/ui.png create mode 100644 resources/gfx/weapon_icons.anm2 create mode 100644 resources/gfx/weapon_icons.png create mode 100644 resources/gfx/weapon_icons_small.anm2 create mode 100644 resources/gfx/weapon_icons_small.png create mode 100644 resources/gfx/weapon_icons_tiny.anm2 create mode 100644 resources/gfx/weapon_icons_tiny.png create mode 100644 resources/gfx/weapons.anm2 create mode 100644 resources/gfx/weapons.png create mode 100644 resources/sfx/SOURCES.md create mode 100644 resources/sfx/beam.ogg create mode 100644 resources/sfx/blast.ogg create mode 100644 resources/sfx/boomerang.ogg create mode 100644 resources/sfx/boomerang_heavy.ogg create mode 100644 resources/sfx/bounce.ogg create mode 100644 resources/sfx/bow.ogg create mode 100644 resources/sfx/bow_heavy.ogg create mode 100644 resources/sfx/break.ogg create mode 100644 resources/sfx/cast.ogg create mode 100644 resources/sfx/charge.ogg create mode 100644 resources/sfx/chat.ogg create mode 100644 resources/sfx/crawl.ogg create mode 100644 resources/sfx/dash.ogg create mode 100644 resources/sfx/death.ogg create mode 100644 resources/sfx/detonate.ogg create mode 100644 resources/sfx/detonate_empty.ogg create mode 100644 resources/sfx/duck.ogg create mode 100644 resources/sfx/echo.ogg create mode 100644 resources/sfx/equip.ogg create mode 100644 resources/sfx/explosion.ogg create mode 100644 resources/sfx/fall.ogg create mode 100644 resources/sfx/finish.ogg create mode 100644 resources/sfx/fireball.ogg create mode 100644 resources/sfx/flail.ogg create mode 100644 resources/sfx/flail_heavy.ogg create mode 100644 resources/sfx/flail_land.ogg create mode 100644 resources/sfx/flame.ogg create mode 100644 resources/sfx/grapple.ogg create mode 100644 resources/sfx/grapple_heavy.ogg create mode 100644 resources/sfx/hit_blunt.ogg create mode 100644 resources/sfx/hit_blunt_heavy.ogg create mode 100644 resources/sfx/hit_bullet.ogg create mode 100644 resources/sfx/hit_bullet_heavy.ogg create mode 100644 resources/sfx/hit_energy.ogg create mode 100644 resources/sfx/hit_energy_heavy.ogg create mode 100644 resources/sfx/hit_explosive.ogg create mode 100644 resources/sfx/hit_explosive_heavy.ogg create mode 100644 resources/sfx/hit_fire.ogg create mode 100644 resources/sfx/hit_fire_heavy.ogg create mode 100644 resources/sfx/hit_lash.ogg create mode 100644 resources/sfx/hit_lash_heavy.ogg create mode 100644 resources/sfx/hit_magic.ogg create mode 100644 resources/sfx/hit_magic_heavy.ogg create mode 100644 resources/sfx/hit_pierce.ogg create mode 100644 resources/sfx/hit_pierce_heavy.ogg create mode 100644 resources/sfx/hit_slash.ogg create mode 100644 resources/sfx/hit_slash_heavy.ogg create mode 100644 resources/sfx/impact_arrow.ogg create mode 100644 resources/sfx/impact_blaster.ogg create mode 100644 resources/sfx/impact_boomerang.ogg create mode 100644 resources/sfx/impact_bullet.ogg create mode 100644 resources/sfx/impact_mine.ogg create mode 100644 resources/sfx/jump.ogg create mode 100644 resources/sfx/land.ogg create mode 100644 resources/sfx/latch.ogg create mode 100644 resources/sfx/level_down.ogg create mode 100644 resources/sfx/level_up.ogg create mode 100644 resources/sfx/mine.ogg create mode 100644 resources/sfx/poke.ogg create mode 100644 resources/sfx/poke_heavy.ogg create mode 100644 resources/sfx/pound.ogg create mode 100644 resources/sfx/punch.ogg create mode 100644 resources/sfx/ready.ogg create mode 100644 resources/sfx/ricochet.ogg create mode 100644 resources/sfx/rocket.ogg create mode 100644 resources/sfx/rocket_heavy.ogg create mode 100644 resources/sfx/roll.ogg create mode 100644 resources/sfx/select.ogg create mode 100644 resources/sfx/shotgun.ogg create mode 100644 resources/sfx/shotgun_heavy.ogg create mode 100644 resources/sfx/slam.ogg create mode 100644 resources/sfx/slash.ogg create mode 100644 resources/sfx/slash_heavy.ogg create mode 100644 resources/sfx/step.ogg create mode 100644 resources/sfx/swap.ogg create mode 100644 resources/sfx/swing.ogg create mode 100644 resources/sfx/swing_heavy.ogg create mode 100644 resources/sfx/teleport.ogg create mode 100644 resources/sfx/teleport_warp.ogg create mode 100644 resources/sfx/unduck.ogg create mode 100644 resources/sfx/unequip.ogg create mode 100644 resources/sfx/uppercut.ogg create mode 100644 resources/sfx/uzi.ogg create mode 100644 resources/sfx/uzi_heavy.ogg create mode 100644 resources/sfx/whip.ogg create mode 100644 resources/sfx/whip_heavy.ogg create mode 100644 resources/sfx/wisp.ogg create mode 100644 resources/sfx/zap.ogg create mode 100644 server/CMakeLists.txt create mode 100644 server/server.cpp create mode 100644 src/anm2.cpp create mode 100644 src/anm2.h create mode 100644 src/audio.cpp create mode 100644 src/audio.h create mode 100644 src/balance.cpp create mode 100644 src/balance.h create mode 100644 src/bot.cpp create mode 100644 src/bot.h create mode 100644 src/chat.cpp create mode 100644 src/chat.h create mode 100644 src/common.h create mode 100644 src/dmath.h create mode 100644 src/effects.cpp create mode 100644 src/effects.h create mode 100644 src/fighter.cpp create mode 100644 src/fighter.h create mode 100644 src/game.h create mode 100644 src/input.cpp create mode 100644 src/input.h create mode 100644 src/main.cpp create mode 100644 src/match.cpp create mode 100644 src/match.h create mode 100644 src/menu.cpp create mode 100644 src/menu.h create mode 100644 src/net.cpp create mode 100644 src/net.h create mode 100644 src/protocol.h create mode 100644 src/renderer.cpp create mode 100644 src/renderer.h create mode 100644 src/rollback.cpp create mode 100644 src/rollback.h create mode 100644 src/session.cpp create mode 100644 src/session.h create mode 100644 src/settings.cpp create mode 100644 src/settings.h create mode 100644 src/sprites.cpp create mode 100644 src/sprites.h create mode 100644 src/state.cpp create mode 100644 src/state.h create mode 100644 src/text.cpp create mode 100644 src/text.h create mode 100644 src/trinket.h create mode 100644 src/ui.cpp create mode 100644 src/ui.h create mode 100644 src/weapon.cpp create mode 100644 src/weapon.h create mode 100644 src/world.cpp create mode 100644 src/world.h create mode 100644 tools/assets_generate.py create mode 100644 tools/balance_runs.py create mode 100755 tools/determinism_check.sh create mode 100644 tools/fonts_export.py create mode 100644 tools/icons_resize.py create mode 100644 tools/node_pre.js create mode 100644 tools/sfx_build.py create mode 100644 tools/sfx_library_demo.py create mode 100644 tools/sfx_loudness.json create mode 100644 tools/sfx_review.py create mode 100644 tools/sfx_review_notes.json create mode 100644 tools/sfx_review_round1.json create mode 100644 tools/sfx_review_round2.json create mode 100644 tools/sfx_suites.py create mode 100644 tools/web_pre.js create mode 100644 tools/web_shell.html 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 0000000000000000000000000000000000000000..4296bbbbbf6a6d52ee5a5ee5a50e606d501da0ac GIT binary patch literal 2186 zcmY*Zdt8!P8m6*Q>z53fl{a?sQYO2aGLB|HjFk<;q^??|xI0{NdxP7hMx?ETzE9ToBIaT!^JxvfvmvvH{!E$vQC(R`!BM?B zh84TsFR`C4n(Le0e{@3!e&)7&y;XiN|8K?1a{N6kl^`Xh4mGI>E%C?K>&EaY>ls8M zW&c@aN^z$Qj&Uz(r-{w#w%>K>FHfV)Kb-G`>~+N0xI5aHsH8yO{?2o>>0v%-`TdiH~`X(i%mRY$oRBMULclX z#~?n&M(2xK&E5M-Cb%B!b-!x=b@JN<$?0;-d033c;#T^+A5Gxv0aPc8}z>9m8! zgLUtJSqcf%MyY=?gpmT2hAPYV5j#OyE7s<4W~SSjX+bq8p08%8AnawYP^7`m3+r6< z&Bn6qTn5e+w+7@F+;oJR(7)})$+S^9a^!7%e ztk?viDh0 zww~*+FcC#eLnf3zo(>XRgkt5B7XBT6fw#{R8&*fegnz$>)fsT5d1p6nRXys ze&tO6x(!am71V**=b_;03!Til{EgOb8m+9L8nt%x~{9R%UG-A3nd17W)dtu)u$2}h`d&=HpTM~9-lAr1D7(bW>kA^ zPIu3g`UVX#>u{(-I!mKjFzj3^l#eEt4Lj}J^?r>C*2FtQ+e;hKF;*K(-Tv3OfX+J? z>sQd9*^4Y_OJNj>yNY79yL#$%4bp)BKZr6C&_&vaOIcG#A4l;*mL&} zH@|DD8Vz%hK8{})iJCtqLHL_=GynN7H%^?r(B4y59H21$+BXhgW6-Y$Vo3bwv=iHlt+&Vi8H9x29ueV5~_viX?ACNB7F<*uf z$e*6U4+9;^mb4`!DVy)OKUJoCw^v0q-_cn>nF$BYVBLR$rDNdRL)kv~Y=r=#preb{ zXI69&P$4oYW$yD;bOi{(pYYn4>us&?daix-ovQ(HU-s_~%ql3-bk!Zej6GL$s=4*vtUjsMcgT{k zAEtz}>V%^N%GO>rVmM?aO>pufY`FVl^yT8D#)iUaWKe^>H6Sa{PQF;5e$~+~`@V{a z7EU8AhPeetEnFFuidaEfIIZuGk%|f%EmzY}ZW991cgh;Y6OPPBtyk~cJcR-OSZ5Ig z34i2ge$c}{g@PF#aGDWKMtJ$5f zHD55lw9*?6-f4sPUc;u^D!48H&R(9&Br&*(VRgRh^8eNU)!Q2`Fc^$#XQ;;rbz=9! zq{NAJG4e`FoDqDmZO~$Cf~PmT86a0<_J|C-Yy=^I^X1uQ6TQRPr_y zDf(3hEK;*te%8kuK$Ru#@SdRBI4wBc@=I(C4{UZwku62UP1-*1m`3trL1KxVCfqG$ zcB#UxxDO^|;*7BX?Pp5`pAcsohjnnzr6_K4~zM z16b8$_e^J0;GoeNv7Coa3~`CPa2{% A&Hw-a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..00e2c10b27ea7f33de027157fa9a9c182cf4f4b6 GIT binary patch literal 2823 zcmaJ@d00~07C)9{Ib`A0Yd(j*zN;2qEus-YtyI!{n!^!O(e5LMaws!}D9Nw;8YC}e zMQKjukTRNuQk{XgfV196lxS- zg3f`xQMrZlw+DW>FP5L>>r8nrSO7fCNCtp7%b@ijuUmpAG)u<4yn-T&P=nOh$_Yxa z2yUEXC2^(CDOs%ooU)9>3xmtL3mV|bJgWY!N*}thpnEHB?+V|V?4Ysi6UMH{9EoNEHM!)i%kdQs$xe z?iq0X`wNO)e~hR#e}*RkP5XxJnrY>!cc+~Ir%@Lg7FWCLMb$nadFvd6+?9m|`RV4O z>*29gmfpT}6uGlZKz{T#QZ>B+WsXHg1ttmV40w%cvSX6b46j{I-_8x%I%KRA@(S*4 zsZ=;8f&IPSVQAREwc(fnKP~nSoy9=FnYny_#8f3aZi~mw(wPrS!Tw0b9Rd7$g>v9p z1(SD-*&-o?pXZ9GoOf59lVECZ8s|c=={VI_5tMX`Y7-yc<{fqtOxeh4ksQI9ab!0p zNEdNEsLj5@QOlZ`@9;<0`}tYA#>SKw{tZ?fpK`~%S&J~Y#YhecKi=;wQ;d;PU8O`u z(~e>vtSYM416d+Ry3I%8sq@~^FGW{dv-ykbuyVR1#Qv{sGN!3Tf21^_Rpc$4?%uv1s2lN68!r!M`}A--sn3PV z(uuAnTSH}J$q_c2QajcX$D5!qYS;NkM;)da=>oUWb4Yk8)F=r zCYLY|X6y9H4@_ibeg5bVmHz|V^nT(@N#w@WncEukW~$*V9g>~sMb%Mh7!Ha>ey~1R z_0|Z17JulJshSc$gRCTUyo zed+x3#IY=Uhw`@G+JNbo(Q&UEH;o>m&aI6kvBF_v4x@o&n$2N=Soud9g`z?YGW!^^G;Y2~EXR_iDFQQF zq$s~9pjlk%_9-G!1E`~XiAb5FuX4Ud6uRaMwyX#(6v;daPK25DE%}%KQS>_Q!Kj=v zc|~AYD34!5w*TI}@V4nZR^IJanavHm_ADYN@vMBT3LbM)^GALirWs!jEySHITRonW zi_ZoL)gdwk-7pCJ5#V!s-V};<@}4!1bv_aJ+U%0{ba~ofA}{|4op|FY?X85E_Zz>9 zNf6o>^)-)DQpWf}xo-Cefw>iz_)p8Txj4+5Ln_2Y=p0Ocb(Am>3@JIW+0~$@?dbb} z%#O{~@@iv@XPNdNhaex+tz%_PJpRgVOVFI%f|_CR)=EN>YJ#5H!4nx!k$@r<+?|wF=4Z&@VlWijP`71NgP##jQ0-HyaBo zf!)~MfK%oimkrfi{)eP16;HxA)_I(n4LL?KL}R*Q@)Qd`8Df^u|56ovSBTqYmuG8u`4uX_!ezzokOR+*#1iqE z((XT_(JzZUgJR#4#-i<$*r|ipGDPlV-NZ`IRI(2zqRc;GJhh%KJ9>*&RZvm{B~Hyw zaSQc$xWgLT={mk7^0vDfk+H$NmCy{g=|I07+MD?5S4_-BWUgCFT$@ zact4cU$%Pbq;^1T#@$)uJI$pWJmOIo!6T$^hdfn}|E#-FS(VMi(|6IUXg{53HqdLl zLQ#r(Nq!8OT||EX!iRe&lw_w!PkJ^rxq6T zV(pk;d#%6e{sW4y%j8AJw!`YqG_7l7jpWtB6W-$2f90!F*5i~5rUVa(CHjGQe5bGt zE4-cvU0bf~STtnHDTe{B-`fjk5z?ged3AhcPs8;<8w~ul8wF>Ek?t-z*SB)^p##~+ zj5sx5Enqc}#O(aDpz(kclnFd*U!bH1FPY3(qOwEclEu-V{SIK_Xj-D4vPT^UH&8Z_ zWAHsEt|rgFhqTg?Zn(cIwA^8d;k;`5fV&n_({@*p{YYS$bi7{DtNp+b@=HCYG+Zs# zlr@+&4edut?Km;zp4F_T91}dEtYPL7WK#XaVw*p}fOOCzKcN$R)4NcU7^5*ThCd+R zw#?trA4+a}>nVo$+LA#x@00YZiRQpf&#E(PK~X>F0#zjPrne&2XF^5s`aiYS96rg1 z<78VE;uua*QVAiYx3Zxb+W6<=LrrYG}%{ literal 0 HcmV?d00001 diff --git a/resources/font/pixel_purl.ttf b/resources/font/pixel_purl.ttf new file mode 100644 index 0000000000000000000000000000000000000000..167680b2ab05ae162caceaabb509e4d0bf9f11e3 GIT binary patch literal 11388 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 X9OvzRFV$cC2^&AWFm(@0eslYKuphHF literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..305401fb76fe3dfce0e816d774c739f291183899 GIT binary patch literal 1673 zcmV;426p+0P) z+j{Fb2!-u_`~6?pb1~yRl*kqffsCB>-!yFzMhHphkYw8kA%qZ!<2Vjf7AfOp#xpE7 zcc^{8{>(rcZfWqA{9T#W|1I)abw2g(`@Z*NPZ`9Pec$)vI1Y>c zZ^G39P)?_3IbR1)?>SQ5(wVjXUf@*>vNQONovmkBcnhK4FUD1V?KtBYLMf7{@vD&3~Eu!sd?TR2} zvi1wx%8r!i|vU`3}W*78+SM33D_P@?kEeoMK%0b*0+%<4*kP-acR(lMSH zA%iR%D&_zC{`>tDy*i^QkS=5GFg=-Vo74d$8PlZHh2)?WI*5nt0mb7Vou)+k)?<&g zjC>&Cfp`9NgnkyS(*9<+n?jYN3UAxw5%o*o!}%>cURaP*1+ooT9& z9sHgFE}FkHz+IjdHKKa2H)Jv6ih$?J0%>!`Vx-RI8o(OtXUDKUtlF&NT?3%c$kd<7 zjJA5ZzC-T^sMcVl4LVRsDh*f}Ty_AxLZ2DvBTlcd0DXJVJV|uB{#^si*gZk=VQG)G zj5nu8%2;XH{}kGm3hkQ-5<8cN4q9QXiG&bB2qA0|IypesD4={dPnltK^SsX;WXyNvfpjhFCA);*C*0Ep>E^xNtPc` zz4~A_ilO%dSpQC*C3`@ab@H82hb6uzc;yk@Iy0WKnWwcCu8DZiK8);WAMXdqZUBpZ z9T49zv*;AgB8#+FcJQy(sZN?}0PkX^2clcsvSKGd@>VH>%o_Ujo$eVyn$b=&4x+Px zkQB(XI`1ulD+PS3Zb(P&$)JmV636ftWXJ|vbsDsSc1$^HFYdEGO>zJFdh zjjDL|qR^usNja*rQeZ9#9sC|BT2Hm^kzLi@A|qwD&F0UKFUYVmtC2W;KZgvuw=(<{NwMb4rP@ea zk+=J3iMK>%HNq~)(7!rWQ?f{g-Vfkic=iHL>bFe8BWsZpOF1iV3Y-<|qHX2?HyOQa z^mOo4u%RmK8A*7yi8JlsC`bq)gb+dqA%xsu-918gnJVo{EpZTLp^8J_LgwyKrL%=s zbbtzuBx~|#+s3Z$TFPWzXVr&J=(S^uO(XJmf17QOgy#bP^6}xg+ZKQQ*rM}Fzx5gf zYR@bx%ua8(@1)laQGT780-+62X|a^ajJqqy$@GlVV`LqWd9|eE8bIID{Dw}D33@GK zhTqXnrU%cc&RMbpR2%ux5n9Xl%IW`S)qT}8NC$poJwCo6MnCY*pzMaqvd3fpSq2%g zJmnDGsQ(W^lr67d#3^*>vt;=n(d)6HO5Bu8fk+=2KpCk`<}5#Bkxd98gxvEF5ON%S TvIFtT00000NkvXXu0mjfEaWx& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b84e6eff77e421554404b167eee6873a8d05a18f GIT binary patch literal 798 zcmV+(1L6FMP)vG-y-wR zsQgD`&+dR3Kg>b@o!?rzZj@Ux24=6WIu^hB24(5`yV9u7@GxDWP$(4sDKcqal!tMv z=_9hYVr4wYp2;)qBV5~fGtZ6DS0zi{?Uk=n%-+hpkiCU&cV^b1Od1+b`bm<_{)&!8 zSC^+__G&yzzpV6hlt(z;6JV__FE5S;iop0+@K|7RXZm%t4_kMtIA*iXwx1=B`c(%9 z9S>;LdR#{rT*hyqP$(3>97?egvA1$!B)5D-bL-ho;bC!FeTw$gYhz)1y?9||ktv1P z6$Oz3R?Mk2fF)<+W?>r}MV+%WG|G`hG_7Q5pVD8oU+RK;?*L1Q0#$a#N?5B)U2&OQ z{LFsAr-&Y;(y`1=vi!1%AAhq@&wIp?@p;85Q79A&g@+(>nVjuMxvVn9kXmp>;>cn{ zDOTpjR@*S!70=fhotpK{ZCH$!9M%CW4Mj3kWipOg$Kx*q<|B}~lMv5}cnXC=p%BAU z&Ry%CVTs6!er;ai5Y5w7`>Y(9Is(O8vVUR5{QI80Wetu+7`^5aR2}S07*qoM6N<$f;5VL>i_@% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d3bd1adcdb667beb9e66e943c22567eea95bf5e5 GIT binary patch literal 279 zcmV+y0qFjTP)N d!qYoO7z=}GbvD3m8a)62002ovPDHLkV1gH7Zy*2w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b6935eb476a427de200c642266f35014502a8aae GIT binary patch literal 2279 zcmZuxc{tMz9RF>YBa;q7WR5pfDp!si!!#s%DJf@E&N=5An;EIeRYJvF6>rIqMb05t z)(~o}GRkc#cVn}+{(PV3{XWm<`+Yv2=lT5c`TX%Ep0`Ir#pJ{Q0D#(9BOHHU{BI13 zKz{FmYJXz@5Fy(j%rC?gERjCldu$|b)iuHinV^~MR_j+!4WdQovoBgjTBrrdzFmQe z?D_5b@8G*a`x}@(T>q;gGMUjhlq3u|)p?rN`Xh65V$z^H(p@pZq!uMe8Z1!6u&gK( zr|In5V|qF(NdE}rzPl==nc}d`=}~kif@&Hs-#!q?vca|{yG&x{E-%eJTY8o84R(Sy z{mXQNc`cJEx<=T<4u{DrlG0_yMXJ6uru_7YRR~i~xkfmDIW8bpv6oT8zc}_rzPv0C zA=#`iEB`7K`ix;T>Ue6TY}~Cf>xQ3aj)~H(+#m-rU_L(BauJr*zdJ{tG7#W^K7=o4 zEH?HU=6yye+0u#-W&HBVPd`_US^n;4*GsUou{e3PU9$5Z*A$6^hy9FND2}Jn{J^>< z&*7%^~7Ddb8rjU|;Cxol7IJ$v%D*AKt&dXskRLLig5EuWg8eI_*{-t9_n%3Y?;n%>ft zgD=6btexlL>Q=)FQpq~6oT8>2?}IElS{y9qeyI>b%cAV4(h)$XxC}|i8`F~XTd<{e zrm)2anZVI!-^hc9_R-_euKt{Jpkk>$KCUg&-4jAF6)nl|v@R=e9p&Rpo^zG3v{^m7 ztKW9Z7;zGjdHNOi27i=$8jFwMYP?k|Q_QL4q8yMTLHS5N1DLC4=wAnL)z(efn!!R_ zytP{KC9MNcUc=U9EPkXt?nEj)fo%GJy^I2G=6jXGPmlWafASFj~cM`hRsYPB4N37MjGp2QCu0S6tlLDd14bDkrE(E-!$bhgpU}p}u zg}tN5>O#zcRDxGoi&kA>=L6B`g*4Z0*F<8m#cLg&Pq^-{srP*kf&D*|%e)%yQw}!i z3;BVM)EGs4ti9g`9HRr+IvJXtP%(INRC60#eDCwIlkZ1%4+=l%v^CF4canL=aH_cP zo}b7Mr%GynHk`$Xhv>G9yv^5oXOuC(`VlMtQhKY7yt(%us}S@V+cDd_;&3S*-hWy` z1j}8zetRShReNay1HpH3-23Re3l=6f!tqwFwIw;Y3{s0gTez0(Dmd)wakM)l&%81T zF275*4ZaG#9H^*~;fE5~0#G!PiP(d#*g|w+`F!nB%{t08+XlN2l@Xe5B~h-`*YHa- zAlsgL&Bu|qVRXl+4c+PxG;Cd7WZjLrr%^2ZkhOj#o!D4t1(6_!}TQa1Xr>9YrL`YOrrIz z4vtxJ7blc!sS##ez=!G1_K=YUs1OaAd`PuX(vn-K#B#Juf<$G#Vxz)wUxMFL7#t!pL3;85 z&GG6TO;YA){R(pyC7^<#2Z_-VA&pM<8ST_K_dJet{T`Tri0*7$00Cd?C=Ca6VfrVp z2s!Q=6x3Cn8eMG64L7>ohM8~oz_MG`<0{f2ZFdR)_1Mr9{vE(&zv+1SZIuYp3F;p* z>lUSx)l|73kP+eq0xI`E$QD1lYq;9Pct{esd9WXkQ>AWqF;xaVg? z=>vBHSI+G-cigl&OU;DsmsIl2fdGtqJ(QqkjVp#g-BOmptZDZGaUXZb%UlI;8+k{?`gTYOC&v2X302ttrJk;7X&*Vq_!$XJw$}j6V zHFDI)tHe`}tA_b{8F=cRfYnd*o@R>Qa>)i|}IFs}RHKo~#eApYn=1sEb+XLB}mpgRCK5 z^9k z(i#X6(QzCA0I+Ra8IZ8!6;JAXJttA%!Vv2N5mC09Qk`Rsb{r8wi$7YaHFztK8r?GX z_^F{tKn9d`x&kJJo`UM3f*$<8rO&t}peevVt?GKRo?g(w>ck=x6taxy%str(8ZD)e zrbo2Xtlu()3gg1XiDIp5#9${clGL* tuKU0!^};ba>2&;4JbrkYeWiS{8YlnCzsTR>J;MM1002ovPDHLkV1laHoMr$3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ad817c6aa8ed5dc09c5d7e50d0855b475c435b19 GIT binary patch literal 9843 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 Z@E^=FRYJjDd+Y!J002ovPDHLkV1fy@`^o?S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ff7e06f741a0d28f9c42e5b070108b6c9cdea00b GIT binary patch literal 613 zcmeAS@N?(olHy`uVBq!ia0vp^4nSPM!3HGP36~W!Ffi$Ox;TbZ%y~QK;-qE;9@oip z|NYlbkDn$xeU-vN+o<=I#aSP!gatONogEp`)Df~{r*+ZYL)WK-<(<*KeISD^ed5{$ z$HN^*vd_-$|gWqHfaqr{xSXflia0#l2C6NjRPU6R_1?xhJXMIZM-hv%KKPRZ=g6_N$kb{fT1DIePu-h2lTocqfLbMVm(+ zzb^U4aHZXTnfFKgQtsdVTeSM%TD$#MMa2r+H|LjqD%zo1)t%(jnZGrd|A~8_|N4fD z?PoThtxtW_oHvf+#9EiYQ401q4J9j3o4W@M zvj_+P4~T#Y)cpeDVMtj;E02(p) ze`x>zp#wtVA^{L~-div7Zw>!tyzzTYkYPkbFl1a5W#Z$4{S)E>WJ3c2{zuJxARagZ zGhhg000TtqU5q{D0u=Ygcn}AIfj>ytn*)F>2;Ce1XYBtMOJJlV{4*Ox$E3!E28Sfj z6y+6Eq-iF5Wz(DzqN5lziDZU0d$BSHfhQSppGT4Gcn zBaY@rGm2yQC4?q1XhzYIk|p$?YYOi(mx%MFd0#GU&4Z z^x_|G|Lx9yc#IEHQv5F$L^K2dwXnPULlgi^835bIcX$6Z?e1=$-s52&fYGS`#7Cb3 zKz)7BfBZi_kwyS0`2Y;u{EyGS3V=(80N{KUkQkTruaD`!b^|a#0u(?448ZN3Bf{QE zA_L0aOJ=|VSOFVg2b_Qla04E|3;2NmpaDT33`Bq^5Cal`4kUrp9!s)74#@9Or393L z3Qz;;Km%w3EuaH*fgaG`Kr3hi?Vtmk z1D&7?Tmapm2lRqI&<_T{C2$#B0Yl&_7zQI?6kG>m;3gOc6W|uO1MY%*;69iH55W|8 z1ZKcv@C3|)Iq(cT2lL=1SOBlUYw!lV1@FLnumV=W2k;4e24BEe@D2O`Kfwmr1i!!* z_ye}V4%met2!@al3c^5G2oDh;5=4e55Hm!DSRpou1LB0ZAs&bi5`bus5F`wVLSm2v zM2Dmx8AukAhZG3(|&kA$`aIGJ;GXQ^*{$gsdPN$PTiH93f|DALI(T zLmrS9eMM2R}EEEqVLP<~xbO1_+GNCLe2g-vAphHj*bQn4U zl|aX#Qm7oNgsPw#s19m?8lfiW4AcU(L1&?JP$zUA>V|ruK4<_MgswnWp!C&C-@H)H+Z^7H}E&@iN5Lg5rK|(MgSP*OoP6Q8vA3;M1 zBg7DNgfv1Hp@2|Es39~FItV?4A;JVJTRpO^9Yh8=?czh3H0HL<}GPn+kpv_KNky_DIgz|b z0i+O86iG))Bju1vNL8c;QU|GzG(wsoEs-`z2c$F773qoeLHZ*Z$Pi=%G8!3=OhTq2 zGmzQHeB>cyF|q_%imX7^AnTE*kY|u>$PVOrWDl|*If%T996{bdP9X0hCy|ekPmpuS zdE_hP5^@>&0l9|!j$B9nMsA}33W>s^2q+4Q1;viyM)9MBP@*V0N(QBXQbwtxv{Cvf zBa|7+3T20KM7f|mP(CPsR1hi@6^V*PC8APM8K@joKB^FP1a%x$j;co0qfVikQSGQs z)CJT<)FspqY6NuybqjS5^$<0Knnk@pEuxlC%czg2FQ}iWU#P!mfJUORXd;>k&5Gtk z^P&aOqG&o=2CaZrL2ICO(S~SKv?baW?Sytkd!l{O0q78P1Ud$tfKEkcpmWd#=)>p| zbSb(DU59Q&H>2Cpo#-BPKl(Cy7(IrbK;J_@L_bE)q36-B(eKb7&}-=L=uPw=48R~U zI1C9x#js;|Faj76j08poqkvJxXkzp*Mi_I94aNcEg7LukVgfKBm`F@4CJB><$-?Ag ziZDkprI;#A9p)6K1#=d29&-_M33C;59W#Nsi+PB7jCqE6iFt!r#(ctj!>nVrFuPbJ z7KbHasaOsyFIEsMhLys~VU@8OSY50U)*Netb;P=2y|8}RAZ$1`2AhaIfX%|@V~emQ z*fMN2wgGz@+lKAL_FxCFL)cO5IQA~~A@(u$8Fmr-7Q2dF!~Vej!fxXbI4q8YqvAMl zd^jPTI8GX;fK$V1;|y@7IBT2(&IRX*^TP$pTD~a{Q)5Lb-1!6yOh&V>PO`Ie? zA-*8KCaw_Ii0i~ZB#4A1kx6VM9+DtQf+R~)A!(5eN#-Ow(ms+G$)6NTiY6tI(n)!w zBGPeEC8>ebOzI%@kS>viN#mq@q-oMK(jw_S=@aP(X^RYzF=R5Cjm$$9BGbuoWL2^b z*_doeb|AZveaS)O2y#6606B+TNG>5)kn723$Q|S!@*sJbJWjq(o*_Rczb3DczmPY` z+Y}^)K%r8&C^U*VMV6vM(WV$tEGZ5YH;OMMh!ROjprlc9DMggylxj*NrIpe}>7xu$ z#wd3wk0^7LMap~1XUaO|FB5_Z&qQV7VxlpLGs!WjGU+fGGg&h^F?lfgGleq6Fr_eM zF&$(oVX9ziU}|PM$8?eD3eyTm6?ZGm|2opky(S; zfZ3ecp4pYzmpPa@iaCingSmkDD04Y;J##bjIp&MZL(F5$cbTV|pE18?USP=-(BdLkh3~E002(_GAPi>}lQv0Y^sW++jsgJ2I zs7ut3)F0G8EC?0?3kwSmi!h56ixP_#i!qBei!+NCOCU=GO9D$eOFqjHmU5N`mKK&S zmVTCNEaNN>Se~*hu)JqkW7%NYWyP>kSUFf}tP-s9tm>==tQM>etRAfXtYNJ2tZA%y ztVdYOS?gI_Si4vUSch3BSRb;^vc6(nVg1JXn+;;avr*Z2*o4`n*_7FI*i6`L*<9It z*+ST2*izYY*bcLmvDL9PvvsodvkkLNusvj(V|&H6%J!XYiygsEWM^gPV;5tWV^?F> zXSZN?WcOeXV2@x=WY1(j$bO8yn!Sm=gZ(1=5c^H`2kcMT7ui?Xzp-y|AUKE|tQ>qC zVjOZD>Kq0fmK;tTUK|XLD2`-~Y>pz1QjU6#7LM~AmpDc_?r=Wt{b#t(+G)2RX+$?{PlnT;N>h{K~n- zh2SD`v2zJ<(YX}4w75*TY`NUH{J6ro61XzC4so5}s^x0t>f*Y@HOh6DYliD3*L$w7 zTwB}-ZW1>;H;r48TZvnT+mzd$+k-oRJCZw@JBRx)cRBY-?l$fo?ji1R?n&-B?l;_@ zxYxONd2l=|JbXOjJn}r6JjOh>JZ?PxJP|yJJXt(NJY_r$JZ(HZJVQL=JP&!E@htIt z=Go+hcnQ30yaK#*UL{@~UNc??UQb>IZw&7N-hAE?-fG^{yq&xQyw`c}@jl^Q{)@M-ax@Y(Zu@CEWk^QH3T@s;pZ^PT4F;=9Cmo$o&1Q@+=HANkh# z0Y9FfjbDIYl3$r$m*0ZliQk7mgg>4?gTIi!jK6`uoxhj=8viZ+Y5sZsW&ZE{e+4iC zQ~_QAaREgEZ2>a@M*%N^V1YP+bb&(xr2-8CZ34Xl*92}0ObfgeSP}RkutUSrSZMq- zI!&3TN3*0k(|l=Rv_x7qt(aCxYoc}1F44wl4`_3=CE6P8w;)oGBFHT$Dkv|gC1@(> zAm}9+EEp%4DOe;}F4!p8A=ocCDtKRTR`8ABXTe`WNFj<4w~(liypXn#nUJH9k5H&k zf>4%Fu~4N@lTeq?pwJDWheFSV-V1#b`YVhTW)T(;mK0VIHW0QJb`uT|jut*3d{Fp= zaD#BWaG&sq@IB$D!f%Avgnx^mM3_W)MI=O&MD#?gL|jDtMWRGfMG8bth}4U;i}Z<% zh};vI6Bi?xgOiH(Zg7n>7%EA~}vTO23OCN3y0 zBd#HCD()!mBOWH6B%UXJOuSCKO}tNhRQ$g9Gx2xg-^8~i@Dl72!V+>4S`y|G&Jz12 zA|+BK4oZ|toRl~xaY^E)#FWHKiB*XWI!vd~dFT>!Wx4^~hVDTRrpME>=||`_^j3N= zeT06WK1Y8?|3=@DBuH{fibyI*>PlKlx=IF0#!6;L9+s?WEa0REt!v)QHr5sb^B}rG7|5(qw5KX$ff+X+vo{ zX)oze=_Khq>EqHTrO!zZN{>rVOD{@)mfn)V$gs)?%E-xR%UH;`$OOv7%4EtE%hbrU z%3PGWE;A|fLS|KFLl!B^EXyw|Evq4GChIKgCmSuBE_+zEO14F|S9VnPf$R&}71<3r zgdDRRznrw3rkt7FJ~@B67`aTjV!0Z*Hn~2zF}W$ZmvSHFe#@ifS>=V~<>Ynbt>oS1 zgXI(CbLEfApOo*Ezbt=C{)zmO{5Sbs1(E`{f`o#qg0X_5!hVG)g>;3(3e^g&3VjM= z3R4OT3ZE3V6tRlziXw`Niu#JSie8Fgim8f+6e|?ZDE26hC_Ye}SNx#(O9`#SswAwW zprof{qvWL&rj(*|P^m)cj8c!%sM4g;ywXRd-^v(ec4ZM|C1nF;J7pi`2<0^8BIRo3 zR^@)>8_Ltluav(iZ>tbhxK-#XYAU8G&ME;aaVps=$5a|rI#q^L?x@VEEUT=mB2`&b z1y$u$^;B(Dy;Q?h52zNYR;jkC_N(4hol$+Q`c-vTjjYC}CatEWW})V$7Oa+}R-jg{ zc1EpN?Yi2O+M?Q;+O|4Tokv|#T|?bm-BmqEJyAVhy-fYIdXM^b^(pm5^)>Zv4Wb5* zhLnb;hJ}WkMzBV*#zBn=jb@ELjT;)%8m~3JX#!1(rhulbrjDkKrk7^8W}4<<&05W~ znuD6RHD@)KH8-@-T5MXPTFP3+T25L4T5(#rS|_xcw7RuMwWhQdwZ3TWXp^=1wPmz* zv~9G#v?H|BwTrduw9jc@(Y~wwT>FFeZylTtmkwP=L&sdlO(#SrMW;}wTBl9tlFlui zS)FB_OD&02SOS-poXLXl#H}x=j9D3q{C z8QwFTH~eh4ZA3N_Fp@LUH*zrYGm10HGb%S~HtIK;Fq$=5Hu`0ZGv+pyGS)J-Hug4- zGR`tSZro(tYdmH=WBk^5-2`pIVIpCoVPa|GX%bgUg%9O)Y z!c@c5($v#5!ZgG5nCU6g9@88)g_YPBXfhmYKDgw^@`~w%G}@(`J2U<7Q9I zR?L2zq%q!(T^=qmZMrqnV?JW29rYW2s|{i@J-Ii?2(ZOMy#` zOQ*|-%Z$r=mn~P4E6r8e)y&n?HOe*DwZiqR>s8k&*Cp3YH@q9an}VB(o10sNTee%7 zTbtVzw@J4*ZtLzicRqJ{cVl-q_i*TJa8U-9ts{N9_}6y9@!q{ z9_=1iJ*GU~di?SvcnWwbd7628dPaNZc~*Iz^BnP<@m%)&<3;fj_EPh*^4jl};8o~V z@73*f(`(M_lQ-ne=1uq3@pkZLcpvaC@jmT6;C;_~(ffxF)`!9YsIP{vt*^gtvTw2PDc?TdJH9V{zwbxy=h-i}-*~^<{>c5g`z!aK z+ds1Z@&48Q+kR9(aX&3Tdq0NX0lyNzGk$}95By&HZTJ)XY5pqy7XCi|@&1MW4gS6U zxBOrDe+@tda0kc*7zelqLh6QE?Rs?ngjs!jq zTn*e|urTNhU4|1Qgpt80W3)4_F{T+SjO`$5ka&@Y&$u;K#wM!Mh=>A(A0_A^SqYLb5|DL(YX<4|y8$DHIOn2$cyn z3Uv#O3e69#3B3?H9{M8mYZy9=H%u|iJj^>RKCCFLF|0rAZrH1^jc{VPP`G-yZFpe# zf$-zut>IV0r^8pmcOqCLBqQ`ATp}VOawDoE&PUvgcpmXJ5*^7KsT64u=^L3CSsd9E zIT$$^`8IMZiaAO=N+-%GDl95Hsxqo8>PFPFsISrJXx?b0Xp89m(Mi!qqR&KMj-HBs zAH5yJ5+fO-ALAMm8IvDV8`BeWD`p|)XDlIBC{{hzE;cAOJ+>^iBX%@)Hg+uz8OIZ+ z7-t^m8yh_+iBqxd`>LfZRh9~AG)+BZ(-b!3Z+(;rNi6m(yIVOcA zw2-u(OiUI@)=G9v4ol8Su1@YwzLmU?ypcjm5lzufaZU+O$xW$E=}Ebh@+#$5DkW7s zRX5coH7d0rwIQ`H^?vG7>YoEF2P6*|9`HC2d!Xn*(}BSQQwLVkKpIDyT$)*$Z(34X zNm^^#aN5(fwRBWEU%E=VO*$hzBfTQMD}6kDKK*9~F+(InJHt66A|pSeKBF(=LB_j` z?M${znM{*R@65!^qnRz4!qkvK%QD9Ku zQ4m*9T+m!_t>9_FmxI`Yf(JDZIvtESSa9&`42O zu_Nu|md2JAm$sIUmOd~2Sw<D&l%Qk#;?;)L-qp#~rPW>4x2u<`cWSt5lxys2!fFa?PSp(6 zJgxawORN>IHLUfiO|31jy-<6%_FWyQMxbF7Q3E39j-8>xF661JpPyWBWNwsh zG;a)O%xbJ_9B7l-H@0Q{|_+Pu)AU+=OTnXwqzQX^LwqY3gX2XnNhW zeVXgE%4vtwk*AAJx17Fy`sL~0XV}gtoUu6*dZyq^)0t~$o}F25W^R^gwrFNF=QN*e zzS8`(`FjhwMY6@LC7>m%rLJYL<#Ef`R$?o?)uh$0HM6z0b)a>o^-CL}O`^@XZGT%v zTTRe&WhK?&8vmHOqF`bh+XL&C8T;91;=dPW5er~gqwNt*+rZcSbP-k=J_0EOPKV6(% zDqW6U(OpNn&UQ_7Eu9DF`Oa&ecRinQzV!Ts^Y_oMUO-=R(?r>I?lBW-ff~ zCUr}8n|Cw1bGsY6uXR7~-t1xPQS7nriR?Mt)7~@Qv(yWE`Fpi`-FuUI%X@ozANGE_ zh`%U)(d454#q5hGFAiONc5$PRwNJ6nt}n8$xUao$qVH`#+%M3t)9=}z+F#k<*FW9= zb$~n|Jzy~qJWw!jX5jk3;=uMLo=X~+TrVYFD!bHk>EWf%gM>l)pxGc}FmJGFaAff1 z;GfG}m(?%3Tu!)Ldb#`Z?%IKBFE89anLz+WwLrFswLl=i0 z4Sl^zzAAIo>T1~4!mF)U$FDA3gRjx9>0R@_mVT}7+U09=*EWXPhLwgLhhvA24WA!= zF#K@@KSCcd8wnaI7-=3E8+knnMg>N7N4-YVMr%hekIs#5UT3?meBJ4K-1QUJyRSdI zzBWc0lOD4g3mYpMYahEc_WlOyhR6-08~!(PZk)O?dSmg%&P~3X+BZFKrroT)dHLqt z&CPL+anau5VB+&F;w|Z0R=2`$9lq6Z>&~s! z+t}L@x6N(`-#&P|_4fGfcXv>CMD7^h3A~ec=ggfOci!BE?+V>Dxa)T}=Wf&8(Yvqi zfqS%j`uBYAW!-DMH*#=_&msbaPq zlLC`^lfIK#lZ}%jlZ%tP4`~neAMSsc{qWSo>knT~K~sWLhEx7ixl^a7ZcHsbLOc?A zWc-NnsNhk{qlriFr!mvw(`M5l(}mM#r|(REn8D9T%~;Pw%p9HRnwgwgdrWyO|JeR< z?BmkMy^m)e|9rywMCFOgljJ8=PX?bnd$RSE`>EDbucsMLPd*)ax;P7F1!oOs17`DQ zTV^L_m*=o^^f}A9@VO&%U2~IjU!E~NQ+Ve1EdE*fv;JpKpZ$8y^<4A0=kxUEC!dcz zfAs=-A@sud1>?oR7ws?Zy!bFrn3tKiosXVBG2c7?cz*pQ`%Cqg?l03`*1sHnxwrr= z2rU>dFcuCiv@hIU__Rn`lv}i4j9V;U>|dN+{QZjimGS6Q!`Ufp>0?ltmP5}-l)BCe{{e||$=u6O-qA#6a9)9`$mF=tgSI@7RUz@&;e_i=T_$K?!;akGD>Tg5e7QRE@ zMZTMV5C2~Bz32Or@4tTV{LuU1|D)i?*&p|ReErGtQ}w6E&y1fArY+?yx2^Q8Q(HH;R{jwG$p3Nvlk%tj&*-1Gf3bh1|Jwge z{9E()+TYjPsBQYT&34>&<@S~Bg&lZDe8*}hdZ&D6aA$rO?27JM>_+aE?hfp}*xmj2 zzmh#e{4<=H1;9BS0P6<;)YJkX77PHz>;D0*qMc)+baiY1000SaNLh0L01FZT01FZU z(%pXi000c2Nklw%kY>XsbqH(1Hm4#C0QErCUq?G zy8gb--;dwd8oYM4{l50h{{?AOUS5Y&ga7sG*Ld4ICfb^hsr#P|o{{;#I@iyiKW`2H z`5A$0s=wfV>ytj)9Wygd_MIyIPi_cnZQBFXjoyAgKf5O9)>rlt8F)O3*%`B*lW4M; zdv|TJ>N}Ok>VZc#>iu1v9FNM80J;OWejd4}GSN|A$h|wjTWMF1Q!Y)Jj4=SBI}COd z$R6$h6z!tUPRkY#xjGAvEOM8swJDIdLw5lv+NH=#cr0uOi1ggBZCdxRlux%UH~185 zY_-P%$XfUgJCKu7-L1Poq@Rh}i8jGGzvw%8^TckpDmQ5JLIyyUh1P9(?N3m)DG$?}wJH3=elzei0XKsnFDW|Jup={3@8p?}x+ zx$DpY5X*9yzAB`7mrP_mgHnrVgP~e4`eCT31(@i6?pkyZ&&on5fDm;)(a_9(5H^7< z0t29kEkr7Rmm6eb*Be3Kr|o}ZRLEjAlK_(Ke-;XqX=K@gXN#(3!76}|zO47v_tL}x zA;X@WGR49sRMbbR^wW#lkwdEh>fSp>PFeLd180Q-Jpw#k;Z|v2E(MxUXew6cmUS9Y z=exqLc3F7#p9%ZYG+BFBH*F3zb3EOa9+`eoPyi@a?cd z`7?cCJ1o%?b|A!tRncte=b6EGh1>`@EP!@-;~(Vc-Z<{Q+cp2_BLBTF8gID4 zZqI)w5M7?VnZ1cmz8jZo5UMNw?~eCoY?B5zZR=q~gExSDw)fA3pYwbBKk(fa;N1GU zweKZ18rE2?RQ&&)wlsTG*!m#K#jZ^>-q)^yYvZ5nBKvN7vXGDkFWo3gMN(#kl|>{z zOEc~Ebzy{O!V!y5pe1*|+SUKCf%6%Z0!20=O>r#WQIuXSsf%P+=l|8a+_5)A4m&B~ zImYv&SmT)7C;QN!jOAXUhUE*$iSaC^8fYHX(Colk#Pfecx$+Qjbl={k<(%$)dNDnR z^6hU2psmY$U&M)fL@#1BXnAq+xtH+~z@2 zAML4*zKh10QCTK(fYm;&aZ>#-alMG6Frq+_W0T3sKwKCV&$cJyoa59okM%c`t{u(E zGJ@+S<=d*0HhclAm_p=0bS57LkOGR^UIY$9h)qL277zhofdT70`gkeV9v0v@Af%8~L~fbDCN$q3hzo*3Mx&rGG-A+N#4NIsDc-P> zB+d?qqU_X6#w}%JB>cRzdksWq6b*??O!K(sLlG9|RyT z6(tkukTe`Nm>yB*JpnnQE@**+33C8Tfifzl=bV*`B9^5=MNuGhcM*puU|530qpSt^9) z2Ww#wD^KSxGh$_?4I>;Ob7;Ckb5!D_njf}?8c-`oFv$dB@CL7+u_bzZhA`I`$+{lS5w^E5l^2mEmACNQPrlsSw|z#q!y5INN+-6SIx5uibX0X}M>Y z3|9#zLrf@4>eg5Q+XfjSlu@#hCsiPz(SOq*T7z{1I#G*VrGRPc;n@MspvqYe0dK!r z?N5k6M*GNhO!Xg`8XleT@$qN?o#jkG5FZ{8zy-T*+$0A9qGQKAu1|2i?3rA82fxoJ z`{Dq4WcKDX`F{pG>1+TIK6`8dkTD^n{2N(-U6XHB+miMVw%=W;-yT4s{=_{Cl_|S( zeGn6}WleR^`-IUin?Ns9g6-D>V+WL|7A2Def@B91Jf4BDGT#?(LacP2oovNQWtrB# zqs5lrp{4@8kotYllpYH_&Kj6$NwP1?N-y7CV#LTb>zO@DwSoJ#qH^iEY2#nTf3=dN zfJ&Us8j3fH9L$kDj+y(X6<2U_;P0r=w9KP-?&OB3Qcj&zcmkIlpQb_{Vy?>qgyl!d&E*m7<> zDpN2*pdh6y0?!0>u!QrheX(GNk|AKhnU$?%dsaPb_A;o@pX=7PimbQ;kETe-n$iHW zgffKmp@j{+$*9Ui5w($jds>X9xSAbCp%wDGXl^8UEGjlZg;W@h2_F4B#m*v6Z?$G| zCd4kFoIexUhxE6M16>^9vz3^*h$7%w;20aJ7_Gqqa{m^4)I=64z=RX5AghxF&niUQ zp&Ct;t6l|9CAG&Z;2ot*7d5iGP}T@MbV6Z4RV)wQ%DZ1{=2(Tw5d?{%@DKoH=|x#p zeD83VY;GiQBk+{sp{D?3QMu0PG}R&n1PG~sI21rD$+@={+6&-cNpd86?7<4MVWWBf zIKwMWfeN*CuyMIEjKk8t*i&gb7H{CB8rmEX{a^ll3e37%tM+weS{brl(K?)Ui#!eW z6r}aMoSW%T1)Sitp3Jgap$;rNvyNy)C=d}^3R9m-1_jE3lNyXyr0fi;Xc*5y>0B3y z{>X4;RjktYGiPp{KeL@Qk$r$t9_>SltmUZjuIqD?_m`@MC7$tHo98&C`feFEAv@62 zf@Om~Q+B*kdgtz!FW9mFY)I>Q$ed~L$kZx1pB1~2CIk{VI;CpdnW}T9SFyYcQ}AG| z-T`rwj@YUYjk0=AwU6#+SXd-jBh}~N$wE$}sb)Z+*#MP{0i?3W4iK$@ERBvlqcp44 zS&LPP$_Ub4N1Um^phE|2Wmm0Q1vU3wyOwCAaC*T>-A-ll094KoLtb`g3I6U?r9zo2 zV^^KTVEpvDP0d}_<`LL?uKJ#@lDOc7a!v@*-NkuC!oXA?lfn1z>wPVl0(}RVWpsI!fEW)*` zp?G~|;PK*)2msxIJ#wb*cI194EsO~+>w*U^;8Jyzq&uCJAW3>-JFgE8ZVuvqDfubZpG?8r)iHob4w78P0@o3IDc#sS{);9%e zP#{*T%WP1H_Jw3gWcPSxiMJM30Fb54=nuleyh${QJ<#9Tw;z;^F+z0ZU9_#~71a2TXhKmx zQDw0*>oBzoM8SaSWjuQ@tW~r7mttELyUiktK3S|xKZqQf+c^e_$-MFGK#_KdXmj75 zg$dGoEf4}2pw@$tr#@m~NFP&!k0KH?EA&ffVU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3fd15ab46e2b527a64f44dfceddf599aae9da8f5 GIT binary patch literal 1976 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..34fdc0463dbbc011a8cb648910fb1a818c34dc40 GIT binary patch literal 504 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 Y`5vp5UtZIrE*GLb{3&;Hnlj>l0N&nAuK)l5 literal 0 HcmV?d00001 diff --git a/resources/sfx/boomerang_heavy.ogg b/resources/sfx/boomerang_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..febed5bef643337cf2a147cf740fd44e97c06232 GIT binary patch literal 7630 zcmb_=c|4Te`~QuyWlLzRjjWTUMz#^fAQ^ob*^)+_jR4?oEb}RZySge`s${A_lF{>@i_wz>ys3ERckv(I{P*>m zZOaZ1fXzL^PH7%gMXDkXs4>)~z~^KDKG5rAupYqhe`9bzAJ45`EZeufWzmNAERa3~ z!O#Nxwo~y*I-SDOMA4!GbT^_y5;(7PmLk`Po10}@#RNOu^LKe913 zxE}?iIruefaFqrFg?aM@?_gM=G3+fdEE6>vhqv^Y%FG9mVV)@kAm>Fwsu)(ltHf3t zT-{3;N`|0dZ5beehh-FtcBXo2yrm`c|j283B`nB?WA{(sT8MM z9C24GS)mvR+6MG^OlpKIKFbb#(H;2u|K3}}!XXH{tmp+}`#S`j(qf{9`B*_0ZV|M^ zPD4p_zhN0A_BG;)9Dc~A(tuMsdPQ&jRn*E>2m~O=Lr6RRQim+3beyMBTj97waW zj@=OpvYb<~0_gjBQ9Vtd2a^im<70T702=6k2ZBt(9z&F5c_tt_Btm+T9q?ElwS|JT z<2qgB)Z;qcvc*YQ0-{04#=K@$1W#?C=Y>=oAn-&a4fdah43h@numHQLS2CDjoDkD! zFt8`Fbqs|9`U@M- z7X(Rm1WA|#Npu7;ykas8XM^AnWN0m69IV`ts%{2+d|@=O>SC=KYUAMlcMyOIr5=3D=vKHe_HziA~0u%H2!F z66MmzFc@S8$ki5ezE17HdaPGO8u)&OP&OzCjQrIS3Bha+EPNg1QBadG6Km*n9JP=H zGh+@;MABb+R-yo4Do0DjCaTc$W4_deZ*CzU z8bE5ZaDWoDt6RTNT8Sk_5rW!a6j3=u5&9RA^WXf+3kv+p~ZO`hqr?4Uj;Z7Gq&|NkT@Yzu~n4AvjgKhRwGL{H@1k}*- zLii9A7$CD0hVKP?kqr?!2R0-I0Cpt?@b`Abhh|C~g$w{5H^=}4&>&WK9#A$-hN91V6>kij}1T9H8{Q8JGC3OfXp z0dvSOMocJlPh}>}NIH(8Ob5#ZFb8rLN5GUd!0?p~bXMr`E(s`x+s{#QhJ0H2F4ISL z=&=9AT z4I+4sLJ|pN42=)HkMVj#l9@m>m?2OW!4n&3v^(OdOmC?TFaj?l41P#@4p`eDi-|S#95c`ts5H&F0GIDM z5~v!WOn}hj5c*q6>7ERgkdG}8l*YjU-PtDY?P#i_B*Cl75b?l*MajFFJ1u6ksl}I zdq=M1Dw0$BEF-P%Ifk$ol0k)3p*hC(>48)Ol@utWcE1HxoiglM(J(JT{KzcXjC}jp za(d7dtbJU_M?DUAT~HoC`ss1(As#3qWq4NB&?TaG9)($B%v5MOQGG2id>jTr!$tKs z&ASkH9;S%ktD4rOqE*|zzX5y4`kPq(!<-((WaQjf$fuUwcp`sWr%Q-x8|-c3fwu2( zKWY<)I}zB!N3UpnFNi|6amop(QG2nVw_LEj z$zbiVKuA!t!C8cy49Etx{lAW#dCW=*L65gFb~>f8bSGSc^~+&Xw^f7JyL3Dcs=dSR zI~BZlAw|r$M)1QRcfcoBW+{V2A?O{MM8c_vp}P%yG~zqqv$E~7d-2Jahc1^UBjZiG zCGp(S+sncRkynI@#jxDjSZ?2P)YT;99i+kxmW#p;GDiS{!)2kzkiiok=zz993?;C@ z2|sh14dOBCG(Ta*$;+q4@|tBYdLMMm+=`@n)_`*-IT4=7p_+I9^D|zBv!`R3$G;dT zh98QFV~vkLb*ifE4XcbHKDPMjTQ*rEFIlF7KJ*$KC#6~T7G-Bw+`a#}b8zhS!l&;L z*G^GTtp2)KSs90G#t#^97-MDrble74Vm3jHpN~mbF{6_ zewk4^q`o|U=9|9VD^5j@x(wlVmkU4pP;(FLh0OZuMj}sH-aI*e!sf}7%CC@%UV&cL z4?+-Ebo}hKoAo7+E}OGwRP)!*e5KxW-(RtUQq(FY7?+jkwMO3zMYV>8_3+>0mtx;@ zQ)l;+3M@)0YTdTJT(=ziHmya$uCKb{eR2QK3!f^fWM11G7sF)xoZViM#xh2TIeDSU!gqJJ^i# zRQVZ3Rh#;He^O6xkEQ$zTY7n>L&m6YwU2nXf&rc1z%GAm6(yv7;k=I6`rR8JmMYav zd9&~NG$}B z`(BsVORQ|xj;1&GH0tjyo>-FzAeTBHu1<^Ca}w3FvUu(D_rSVui1j;rjbegq=H_JF zFyx$*;RX5}#0V*o>s$#wPWmqhk19hq)6Gnl6R#KeT4Z3wEhIEdCZz=3+df(F%hgLb zS933X$=AW^4IFmG^V?TsF4#rvSdp={E{s^C%2bk%@MZov6}?OA&$sDwl6aay(Q=7E}C^)(HDJ;rq^cd)c-`4Y#D-*zFXRnN~#hWSlj2>foK zZ&tiZXDS^tju{l5dlFSU9woa{Z`O;Ol~8T{5H9LGQL!|W3Rf2kIbYgX_vvV~y!`K# zsz1KQE&&Px+wBK;%BMdVpE_Su_NDktF=DswXw@OZF*h%_=Y2!3gT7hiNAD}X+a^p+ zG55+#eOKKb*NA<_r<%1dY#i~jF@Sc5!)&rYJ%P2rH&w+M!Q$pSOmYraXNevCnZtWL ztH3xhFsUe__;uthM@#ns@q8(K{PUdS7setdp1th|Bz`RP{yaf?U%l+(OKg6NTf8>) z(_DE{%J#SDRU>B-*KQp%yY^J>{iv?PH8RgLgQYi;!p|;_^xKN0?@c(yE%e0yl!)r* z+S4gY)p-ZhdgfBRE2fJS&Xw=`Y+l_L^Rp%0X=+Z@K5VQ@<(FS*E~|0w)LDftmez!4 zS>Hbl@w{6L4=23WwxQi<@ed8HeNi>7UNxWarDUOIs2ejR_$YJj=Ap2&#MNoXh$KF@ z(t4A|UsIl&O(^1rJ`PbqZduoyPK)GQ#Zq#EAcqv;#2%(m<(c@}WTH1MV&IVWdd(W@z+%%tqsu@GGBju5Lb~o;n<*yB@ylFYDYG^nj_g({KUb^40 zerhO6l4sODohw4Wc$sdKo$&lqr6venC|@Ox`MJdmPhjs~sJPwsAS2DESE%tF>+zvP zlugEB9dWiyWNGMgt-|5;fX&5^mL|pCHM2s6xKFTJ{ z$z^RjZ{=fr=7%V=KOfR9G=D$ttvu)iGrqp~u*{?(xb+~mdKYQkS@(-QSA7{TtoY%aUe&23=;avFI)7Pg;AM_0A z%!D5Nl6R%w(IH1k=jhe<2Gc{0bkXq)|MTDGJotEcQcna}`e}>9w8gZ1ZrjPPT`xcF z{3>Yh<&>*kXt>qo?yq~F8F%^}&9v&B#O$A%Z;6eGA)1+ZTq_gseipTh^7He|H*yM#-?156WH=`EoZ#t zocf{9x(!1W&vi&C0sc;B5A+xks=XfmnU9KGFIS+y%L$*-YO)iLLLba#ZAe(|E| zd7XVBU-ln8g0Br4)Z2N%=G)}T8N(Y9t}NNA`jRbXT87kb4Nvf)x_*3 zh+g(tW$pK0s%wlZ5E8N9zt-Q%_g>)&y@P#|BeCC<_v^X_ zeRi}BE8J|9(>p=s3~Ot`W16jZw?(JmZY#vNd|7ZfzB9!!V);~2s|sw59`Dj}HnzzY zMeTmt-n^E1RqZ0(#dHH%JH7cpaU)y%W4hy?^Vj9a`UW}^NbFD*Z#!bNs_4QFidM12T#pY5WuN_Zgx5B| zzW9|Sa5R6^(>-VX7+&jFd8VyY@A(qjd3||04^Zm9^}io-b#=zy9WLel+fKd|YQ? zMUkR1bn8&M%m5oF=X#h%thy#mXAEclYjcrsGwDI)(%`sFr2I-k#mnCD+>HR(`Qww7 zh2uZcf7Ma)vyjczFSM=}rW>KtH&xY5(BjHx)h1Et-D{*{joi0>Pg{Q}9p0gm*;Hq< zC)GhEz@lVZ#rU zGWF8?n(Z~kQ}m50Nk5OcL$91068#`Ho~P~Gv1$kDm+HL2Hy2!PbfpLn5Rc!S>zzBy zjwt@r*Zrvz6A@xs^8R7vd+VwRzhJed#GP@+mx4_jqeAY-?=@1&IGg6)b!NJe2CIKo9RTnzRYA%^mr8j($Dr zMS_Oj(on?7WpwF&nXemT*nMSxc22~)*Q&epCJYT(O&visD)LG@x#H!zt%ubHqB3Ww zipu%1hKdno-vf(!YS%MYN$cw)v|alm=|$^B>#hp^&Ne#wn7 z%L_Hl!Nalm6>8oNdQ)~0<%RCEr=*;^>azo*Iy%*4x4}ht@^>_1n ziF8L!?a}jM{&RjGXGr$FcaTR@MG^>RnWu~+4}F47@`0!i8+x04H zXN=F_6ZH0c$K{pIaf)LJ^ZH+Uj_eK>-*gfC=_mfQ;I;PD#{9sm!5d(XS9U^T1s9bN z#&3Ij1AafBPbvsoT2($d{cu(OK%MTxtK}t6gq8xGEPHd8bn}SEP#ys*>Fh{7vI^pHV#S8Z=uYQMzbZLE_pWGmy zBC=hL)ZETFSRwmECTFM5F8wv5`{&B0ZI{*{Bf{C;5kmE}LN@{90Fkv9Ge+ zB2dH&3|Iv11BvdKZTJ)_UCRzvx)EWu|$;{C4a1I&YA|T#IhAPl)V#940Vb{8^Q! zPRC9gt`uSWA<*ilY{ie>AHo+x^F@)n1Kz#W@D!YSAb!osrMoKel%=qxO0yNI{Ep?M zk6-_AJ*%79>>=nhyk3OPDj$3?ZOsFAupm3Aw>Ca-Z9&9N)Og}yf3xL4wQ6bR+WzL@ z=V5FZm*x5+@p-S6+xdT-n8__`&2V}X+N>70^1Ock{QE;b-na8&`J>g{ORlx9r8nKQ z*og5Jz!M{Qtk0Z_I*`Z;wJM!F1RWE&si1d*>r_~vl;0i^$)8`><`X){S2s129uoXj z)L_9f8$q1S12Z)({AmaM-U&Q>Al~8nj3(7L-Dd9ui*4<7YumzF-`q@>+Wj+9 zAHI?2zB^6UVw7Ko&3tmIQ4Vk!km_Qwojz@xA*|5qbV`4Wr;7qts))UjM(v&Xb3eQ< zD&EWnQdH@aSb}xw2O0=*fL&151Ue&d^VNg68mqC`n|vL=znuG8P~N1gROdaX^k`!l zS=Od)0{-@>*|n#n(}PR?Y8AEY(Ic(RhH@^MkAj-Y9%TC{Fb`ozK4Mtr<40+SVzlp!|Ly0P5k}jH!}*; zSwEDda|kKA@+n4b+M<0uuU+O{XBJF4%D-~BmU{b6 z_{HW+)&8sm_g`aX!(=Ug_Br;BMo~{%@@P>;xVDEzb*`qF3Dx8jhB#Pqf6gaXqr!B zde5Hh=A5)MvMxeuO-iew6CT@Y-9scTb(gpf{?fVDx;mMEN<}W-xh|WKmPgveuO-hiMcQzJr{gv->@j^4q?|g__N^QbP_l9wLAp<4`(2HCjbBd literal 0 HcmV?d00001 diff --git a/resources/sfx/bounce.ogg b/resources/sfx/bounce.ogg new file mode 100644 index 0000000000000000000000000000000000000000..151f582469aaa25bf229180fd4dfd84d97cb954d GIT binary patch literal 5143 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/bow_heavy.ogg b/resources/sfx/bow_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..abece51e2a89ee933d0865f34826cf486e062496 GIT binary patch literal 5396 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/charge.ogg b/resources/sfx/charge.ogg new file mode 100644 index 0000000000000000000000000000000000000000..fb22fd6aeecd6c99c0c650057d9713a758f002a2 GIT binary patch literal 9623 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 a`0;0b(Xa!6+%|aoGWTK_$?iOK1N;wz-5)Ig literal 0 HcmV?d00001 diff --git a/resources/sfx/chat.ogg b/resources/sfx/chat.ogg new file mode 100644 index 0000000000000000000000000000000000000000..042e77ef5ad2aa6fb3f5a343d795fde0c3b9e163 GIT binary patch literal 4904 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/crawl.ogg b/resources/sfx/crawl.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3e31aeb12778110c940580ab3512336bf18d1c73 GIT binary patch literal 4498 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/detonate.ogg b/resources/sfx/detonate.ogg new file mode 100644 index 0000000000000000000000000000000000000000..39446d7a1702f8c907860cad29eb5f281b801b92 GIT binary patch literal 5280 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 Cvz{XW literal 0 HcmV?d00001 diff --git a/resources/sfx/detonate_empty.ogg b/resources/sfx/detonate_empty.ogg new file mode 100644 index 0000000000000000000000000000000000000000..777311e4177950dc5f42350fd161b1fb426b7f3e GIT binary patch literal 4698 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 K(M)_-qyGZo2eT6Z literal 0 HcmV?d00001 diff --git a/resources/sfx/duck.ogg b/resources/sfx/duck.ogg new file mode 100644 index 0000000000000000000000000000000000000000..57f855fbd84c202aa1e0b7eebca731c0e2275787 GIT binary patch literal 5125 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 JJjETB`yYC9RQhn}X$*m0)FMMM|YchKQz)H7<}^ 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 O&o18vfdlIS&;J1a)$ut1 literal 0 HcmV?d00001 diff --git a/resources/sfx/explosion.ogg b/resources/sfx/explosion.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b4eaea47ceb2a11b7badee5c0eb4a0fb4f4bbf54 GIT binary patch literal 10314 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/fall.ogg b/resources/sfx/fall.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b7e4aa034aa86d80c2dc9f13097f79a2e0b5f1b7 GIT binary patch literal 8414 zcmb_?c|4Te`~R7-jU^2R6VhOmk!?gF^^76QL@~-vF%;2aYY`euWf>kZL?p`$M#$2X z7NxQ)AtXtPP-)RZ8-34==XpNQ=l93&_5I^}zwVj2&biLH&bi*#a<2Pk``*3I015uO zM#UPV_+-B1Z!SU=arj`EUr;!|1EKE1{{bNV<sr$^Ky8W{42tHMvf0opFUKVLoIU`fo1?GIL`JI+cufw zmbNFTV0Bcn*12Nsi*-Srbvj*jZ?EeKrrQGG=!9uGgb@?W^9KO6TPwN5ByGYigX3Nx z#yC}p1ats|;W#OqT8zUi{^2L{hoAr7dudr503cq=A1m~?2~cWED=g1bWxXU6$SN|H zE6LrKTu#Cm;iM+5&$-e9r562R)BNc14@)LM1K^7>NsMd5qtqCFm^D_Rr8r2v!n<`I zo5rJ_C7{U-`NzAi!5IQ5ppH#A2AT}Vz%K%uMYScA=J+KO+El{t6WeeJQijuoncK`d z*csc*I`JiG)L;S+<7{91N`Y3z8_W;CWI>=YiLKcGd}#<^fC{5USNNyH4KgqSp~1cmQE^L!gSg)jJ84@A6Y&8;^nkH(%E(Y-$sB#_*YxFIlMa{ z6p+X?w|ivd@V~tIz#;*3K9!c7<5UW_+rpN(X79#UmCHGK((+_`58d_tiP+UuZG8lC zddWFTdpa?BAA#eHB5*mZ9%3uIbkIYab6FJ{PX<`Qbb(T9Mbi?AqG}0fVk<5Jt=_7_ zkRpq!u`rYl97C3DcZMcK7K2=>c#%JvOi;^$CPk{ij}8OwMHqx40gBwM){3)YTA(!9 z7_u$FPl`O5&$@3);IIbC1nw55MSFf`hUpqjRv(qX;W<)*1q|p|`y8rTlqf;EtUeFO z==Vsi;wfpg;{3bqI`T8Al%Oiem{iGh-PU2hN@F-d&LVbUN#uEUl%g&8@GBLui{PhdsK0G9+1(d6kf@j9JPR& z%X-2!peWr|4@*L=VDT2g;%?zud{KWb-ii(1Ry6<_#ypo`C<+@qvn1;3(7r0(oKN@+ z54tu&45lEtPEwKDS_EASfV)@@HlYW{X3@|}43?iXnS-Hq+i_Wb5QbbDbRJDww<${V zB${HU$${3@SO^*9N^H@Wa1Q#l|iM*knK%H)fiOjK3!<) zNfm}8g;KTCo(L%@h0CK5Tk~Q1ArN@kFdg&z2%JH~$)!F{zlRu1K@_;qpvPN&Mj~+7 z{T{)BJ`S7ZkwRSR%b*4mv#I>KENZZBE0sdvQ(LQAav%xk!al4sq~P4z7Ay@yFbLCSs5M^(9fJY%URjvpj>vJM_>^EH9}(WBU#jwo&`won2k%PGNFzTn%4Yq zX#xie!7Rhl?!&XlnMs_42NItFPbEI%@8e3EETF`S_zWDQhtEKhp{>fB5CGR?edd9< zb-ctfcX7amk=AYi#XziCFs4utDysxA!uSZ!od(Z z;$gl`ID>vV0+@+|9{j>Pgf+jf?K$i${Tls<=s8b`*Q4}A9xE6|v{Q+$7d=F=5$d6ORerc&?&=+q2RKI+OYHYNlm zNgX49O^_}INVsNr7MY%A`SA+@OXzete6+DkXv59Hs$r=@;4}WI3)22E!+#9Y4fDD7 zA(#nkoczP%&lC!De6gfs8-$m-FDD+Nt;y~om*ntG1)(&YX0vxdzi;6y%BE5w1#=YN z9hnevfs6Vvd3@z{Tij?>0&S~!*x+S+GlUQa&%xM+Ud*f=Jhv6jg+s1QdO?-1xCK`k z!!!X+b1t~Mv24(fKP3EpBLJCVVxU}Dd9RzD*;pUc}$kUrpJ6DA=d@F9sEE_g~Gw_af-~ zw*aaH0A_r98Wi`DXN<->H4a&+-4oss*iHdBcyN(~&6ePjfyNY2i8*{|mLU8u&Hmog ze|GnOOkqG|?hAn3Z$(a>bE^9;t5=g^L!kVTROgbxU}#3A!50q;RmQ;yBFeoPNKHyeSptdqK+P``i1*z#cmo z`yS!%H;L^shv@;j-zKqDOoL)pwqIrKph~oiHP(r;Rk!ur@du4q8iPV;^*G*rZOV%& zvzenntG+g`8m)Kt+e>(M%)d-nz8U4qOeanbg@0^Rq%oJ@ZTAY-yNex9F}V8;7gBXi zY!?&CqeXu({#JOLco(I?gkR6Xd$)Xp5o?Xz#Xx{-GSVPX(pr|fcZG{m=(spmJsrj# z0uW)&hP4Pg9fA#W`+t?4`GQOeK$9^4q+2_b69XXdxgaDuA6Cro|% zjMBbZ*_j@R10RusR0dW6Fu`WAJam=FofbjHiS4*o_!hi6Ej_+3zAT-XXxF)tCZQ(6 zJ={Y)i78Q{O5{={_EngkN+XsN*Sw&Lb3~!%paBkt2Tj1@jubF3Az@9?(2=(IJ+>130P3=8HW78kM0dbiXFj@U|A(8yz zn*RU;i!r3&(NP#|5p)6mqa&ZYbD3T1>Y!a|u?Y!`_$1#Q%;;06Q%=!i=!^t-on$1& zB*Y$zJ{Eb1Cfjdt$2CuCt<>xXV9+SvRO58BJVFKBZZqPZdH7+CsX|>q_MSJ7HWmGu zt}fg8`%lh0*BMqq`~d?*qJ6q{@*38at=G?%dE~0HzVdu-Kbr5|7wUN5Zso4(w<>y{ zO<2iSrk~_~OnM?E0cH$qemAL}THY@KerTW!%xtZFnd0=zRr6Cfdm?X+#kM>e;;wz7 z%shj$jGT$ZvGuEVbHm1}ZRNwChMh}iOGo(vkbrD(!R{Q0z5JxZ>%#Sz^{Z2Cwc}P% zrFY*%Tt7|oux!?EC|xFxH2;l5#4~q%BJFY$p{s%7@6YUDB0ax>AR$hljhbtXJ$^ZL z<;;2E@TqeTjtn)*xS*z7mvtR>7|;Df`E0=}zY5X0{KoohKjBOtE#31!%6rn^SjEvw z7~ko!ILsMNs^L_Wf!#U0{D6l>U2SM$*3W@ItUn)5g@1CdH% zS^nFsN`~`}a`cd6xA0ABjKx4HN)6dMft8GZWHKib^f{!P#S1UZ8PHg}-TfKvs(o!v zAc@Sx-1xLsqy>Oc6MUbw*Pcuz2|dkty+M(tGgcX5M~~zIQS`Wu5-HmzV0EMN zuY$<;BL;{O<+_QFdIHkKCzu0oBA=ZSHn{+ZYCMVm=prHdPb>27Qt9idVkNxOOpkA zyCS=O=g~)qK0?;wC9AbMC2_I!x!Bl(u$?<7Q(Gk^p833nKZj<+y%;!75vYtXcJ81?YS;?sK=TAmbiKMS^ zwey}Ds{wO z{;;#)q0E8X+a>uxpj^-1I1;t`f^ou*&BH_GM>aeystos7HLtzx%>i!(anRj7Urv!w ztDEal3Ql{jhw0Vyoi31Fm@G3qdw$K=9qPKBIr)j>A9iMZ?>Waq9dVqwR$_8KMOepI!+x`cZOZISHZCHdLSOsC=Cj1Hf0J z93A%(Km8Ci2f3EXwOa1-G@b0r8M(-AtJBdi{Jn((&`7n_p5ZdEY5+E8PlWb~7ow7a z1MKavQ*_&>y#p8aZ&;0#|8X>;SNzrvnXA=QF-t4kyFU@Ygl?T7MsF0RPed-_Bz2^_bGvyGYXeat zU@;KWC+-`!YrBc2uMTKBLxS5_xFFonfrsnTg#oVYsUme@?vI9J%)Do}em~0FrMmOs z0A6vjPHFr8F{AURchnzOFk!nL5WPgyw!q({yA{kKY1u9qQ5h?lkxqj zaqw?$@0rPQ4YAwHC46_wH+k+rEGGK*r=EP)d~7->)8^&k2T4HU=M?bBNeuT%Oj)0M z|4HR9+J;qFay~qNNMsTqeSdr`YxaVE@SJXaRqJ(>_KQUW76pLl9{~;LjRp%cl4?B} z3pM(jro>Rwu6Cu!qrVdIeVYR;U6QR)G$CJ>6>a!wSmi@r?AJ*95;?ZDz9fjXZSKDt ztf!ONsg?BGQ3#kwLCJspb_C!=y@aHX36h@9jY^3ued$W{p-AMJb2(5X6`&x z#O-$}y?CS-T_FjciR_O&VH%2-=2`7wL8jj@xcm(;rO8OTH^+qP|R)452uJpnB6YQRnlt@NSgF7OB;xIZP|M zFHne0#0(`|HTH_XlZFf$+gXGZSt0D$ujnEQ=v&@2zJ@u=D`4-&WLnsLHKZtb^MJh( zg}P&yQ!{gXjjdL+JT%VL-kjEOV(*Sa6|xJM@IyRpNpgGP$ z+h@4LT=hhhOUlm!SD1sy^e}~3i$IvU0ta2cslH!tFl?+6phXWdi;3KOVZ#6LDb7J% zwPGv7VjG2bSM1_-{tVZ(C<~K+M3`L&t<>JXreBVD^AhIg387?QqEV?qNd~bEn6W!b z)=skJ)xdFZqm2I!$L1UGrRQag46x5segATmEC3(-FU<~Kjk*1)v5>Rnsk!^{iJcd% zt{FRhxfh^(7SZx+=t0Q0Us$vwYS$l{nWd@DDq|kjl{FLECM|wIHF!hOJ;Mq z|5z_ox#KCz@!^j@dgbZgFZ{qU2wHa^v;)u|^F1sg>MQox&0(JvDZ{x^U^+yy$>iE? z=>D?L%U)q`jjWq5KW(!6_|Ms`Z%b%8VZV3>vNNV}%Rk5{v=jx~_wSr1M_i4gnBET8 zXPdsUfAk<4-!q_3qTPwzz&h1j=@4HeE}{yPBw=Fv_`8<{yAXj9)43~JzCeD3qC;NywS({;61Bot5NACqE+78X364-46;QPZz4 zUt*;ZpgL#2c|+EfsXUW!X?cmCYnIhl7PYXAL;{XaM=T6|9Y!@!oHXpsPIiRQ-VE4* zMb{l8bxOC+KQ?lHle#dGTn#2~e2rsBBeh=a`pr=X=|2yCZ7XRFAFeu?zIQgx&^FyM zOBU3;`UybgXXhX33}?CFSSQ{1PH>0$trOwo^Lut!C(}mSuxy~M*U*||kIYTFdvhVs zP{Mq5DGNt2K3q4wbx>>dnvk;7XJtO-BOSr$5ls7jwVRBqihEtb!?z$q3aNGHgrq3& z>juqb>VQuc&A)?MWp;zo*zxm6Fo_F@IR1ft{3S0T={me7D&`l<%g97>9*0D)z zZ(2G5Zo+6NFMXqPMB;cs*7uw(uHd&1P%DmCH^@Q&i}LL1#g;qwE}D$j-#(iKts?>G zVL|X6!Ns=2Lzi{)a`u$UIHTj`kI%<0#$4)rd^UBg&oc$#ld1Eu^3?T0kFlK#T~Vi+ zU%aYSRl}obiH6&F3Ipvx-cxc*S?ap34HvS|vU=dw-iRS?aNrI0u>V7&Uiq2PDVKnJw8ixiN<4L){PmAe2~;BAyF@rk{J^OE#U0{$r2*LNz2T6|s#SxzRZwNFvA%Ns`(4Ue_ygQMceWTQ)0S0vSXopflmRznbo z%TvwEjBj)_R=pPXam5Td%*QBr96YB!du0LoLeErN zgr>jdIdu%RbBCG>R;IoAIiXu{`Ba2ld@;2s0Xv1q@AXb+ zHs86Jj`w?;l7qdGaIf7ebl(}0g_yLa z!E+1>*wSYf&cX!H6Tt=PUiFWnn_jrCN5qsGJeGvKvo~hLzUhYmR3mhxQ4@jN`w`QRqqT+mtjoA%TEO$9+pU ziA0b98lkEfF*ey!>m8PJH0RYO#;lm!sUl&oSQ!xQ?lv4^{%qCEXxOxVJ`q^V9foM& zT!~0}RO*HK_j1-RjvF%(+(qGBe5ibAQ~C4TZ(eo2Lw;+pdf&M&d_kn-Ti4qL_aIX% zI!gOir&O`%mH<8XO7hdU)C?cgJ~4obf>oPBMEkv~vB_UlP&-}6xOyavgr(}3g)jjA z`bO|DzU%^UdcB-z`-KVR4SD%r^)3%QHd~mo^i<9z%ZSJt1rS%FBiV#`WuyDmk*ZT$ohCe0xc$fC zW+W!tgcI57xaE%T;QQ;Qn@~+)a6y#h_9|)g_ScEg)o+7O-&^;%MeXsb$e*4RsR7w0 z2ZGsa8F8!Z0}reNX2vp4zUv60S~_SxO);q07_5}1&KB|wa+?-v#6`_MFWe;O`64L8 zv}2#-av?`>61k`la`(bPp6>To7ME>KSMPqN3#<&A(i6mXPv5Tc3N^Yq|Gf0P_Ujn> zvpV?f2g*0rA$Ke7^Xy#=eWY~>X+4b}R5BIWh6nxgOr!PJ zVU06IQ2pmny?()5TThW!Iujh3gZ_8s4c*Mm->p8lwJrj` zt-o~JjPS(V7Ob~btIj67c&Cqt8xl2~=A>qhoS`C<>lNkEDq~>r@YBrg%16WBd&zcI zAKJA6ks~Q=S|bdqu*>0Yd!ksd~c?@m2LVHfN!$5=-0^?%dD9a zXi}B;A8V*dKYJ%6n-Bk5SGR3UwK>(-q&w66O=ZR_Rno?~CJixF*Y77$@Lvk}|7c+H K$BT(>q5lCh@$$m} literal 0 HcmV?d00001 diff --git a/resources/sfx/finish.ogg b/resources/sfx/finish.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b50f90c2a1c1bd35efa36f878c1a8f24ed37e521 GIT binary patch literal 8859 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 B4t&K?$F?zl&MS7(M^F7(H3QTiSPh{H_yQ|Gqx$ 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 Lr*pR$wp#xI!Ig&D literal 0 HcmV?d00001 diff --git a/resources/sfx/grapple.ogg b/resources/sfx/grapple.ogg new file mode 100644 index 0000000000000000000000000000000000000000..7add34f97ee6ad6e2baa467f4f461dc4b1c93b85 GIT binary patch literal 6321 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

0Cw4GWdriz))Fgr>x;lkGLth=j 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 G-v0nWm`^eQ literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_blunt.ogg b/resources/sfx/hit_blunt.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e0c50270d74407412e629969daaf20e5ed27c0d7 GIT binary patch literal 5031 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_blunt_heavy.ogg b/resources/sfx/hit_blunt_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ec624b85b5554ba06cd8ef3890f52081407b82b8 GIT binary patch literal 5805 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 IMEolLU+vb|AOHXW literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_bullet.ogg b/resources/sfx/hit_bullet.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e9ebdb2c7fb12e05ebd2dba1a8266297fca5e7a3 GIT binary patch literal 4562 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_bullet_heavy.ogg b/resources/sfx/hit_bullet_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1e4c2808941e6e1281b3063c1bf7c38793e556b0 GIT binary patch literal 6233 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_energy.ogg b/resources/sfx/hit_energy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..cef0f6bc06d5333fa280112ae14359460ea13186 GIT binary patch literal 4994 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 G2LAz6-$3yI literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_energy_heavy.ogg b/resources/sfx/hit_energy_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1c7a112c432d2f891732aa9187a58b6a4eb8c965 GIT binary patch literal 6176 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_explosive.ogg b/resources/sfx/hit_explosive.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d87ce3799e167f9bdc584cee0d409861fb4656da GIT binary patch literal 6294 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 Tp!@1lCEPp14DcowXtMtUO5Pvu literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_fire.ogg b/resources/sfx/hit_fire.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1e1fd0ac27cb4ac05971212398855b94dc28b739 GIT binary patch literal 6175 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 QO`9La?52svACrgv15o&}q5uE@ literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_fire_heavy.ogg b/resources/sfx/hit_fire_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ca10700ae5944e82fb375a040162cc264183e1b5 GIT binary patch literal 7322 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_lash_heavy.ogg b/resources/sfx/hit_lash_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8cc71213b44c3370ddf271eb2bbf93f55be629d0 GIT binary patch literal 5558 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 R6?~UH{C1DlGrg9*;J@k^UFHA) literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_magic.ogg b/resources/sfx/hit_magic.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b69e27ec80e8cae6a09b077b33813e7901bdafb8 GIT binary patch literal 5265 zcmb_feLR%e+dpJ8B#pP4?9h0bm@;jnv|cMiUM7@5G=*)(OAJ{?`W0GbveG0mglNMI z22-dZZ9Wwl5>{S@O14r|M5UsXe&@bb+dh9hpXZO~{@gQjo%6lUb%__<&%4$=`!%H6 zZIc5V0l-nQD}E5OQY~Aw^mt1P&r(}S%{tUhy&^-qWJ{@8Gmhn07mz}|d8jkZ3m^jo zc<-%I&g79iP7%(#E%GS=N>2#EZIMbuD5YAAq(`Mv=1|or5~6oV(=jEo1P3Jn9!jN% z6i%s4kLs|1(lFt7dek{H1jPlbMlC?BND+IZh-IExZO@z@s*HZ|5c8P** zDMjd$8WeJ7`GG>?+`<**)gi6bCbz0bFRwy}I{~oOU9;&|O+|EE005X@j8lrtQy=@J z3%8$91>ij)5E3e$zqmRBFp2PwF0#XIS65fV{3f_O4o9y z56KGNpH;FyxAdqkssaN8S%XhPAsRNr?ODYkXG)M@mzAXyi^|GEbBja%_gB6^uN(j+ z0Cbuo@s5%B=19aVlwra=Kmfqb9lt5cq&a=o;T`@p2v7xinmN|+ zy$Pv+LWZ?-_gdHgcSwe3rNN-7dLm2 zsA)yHw3akVN+?O_fgy>6{7#BgP%LAt5MI=U!BYeFuv}pDq*zumWxie_mLerYWA&wY zt_pR&9$%AolfYG{I-g{zP!%CcS1Jg=Qb~H5Fr@N$_%Y$awJ3l{d4QpI=t&9oJX;J= zphl0=($wk>%X=@v#reixl2lx(H3Q2-`ZsH?b1n-)UmcQGK) z!#FOMXd6fg0Ugda^D^kP5HSRX@tLYrnhuvwqmi@tjJCXtE?Y-oVjA6ENVjL5aM?5BsKk^V}gr|=||ymna8SRGKRQ-kK2q+&ZEc% zm=zWfa3)IA>Bq=5JK^LdvZ!z}1lr7`J7dL?ksgLvBw;|HS({TK86LESo~!SGb3jvM zDZ`Fbg-+gtPVU1oMofK~T#DnkiF+YnCfUY@rf}@BIa9U$p<>B9KhBs0rna0SEI}Hr z8w>PS$|V>B(0~)-5<3Y3J`1bOg@(@`a78v3YEX|oHgwZ7m zJz;%6G!JY{SeUDRY@ViewK~8k0RonuLKz|O)9Exds*A;ZJuaOdN`|2x#&fsQXksrH z3Y4G~k%UH(=E3qqB1y7fIY#8*l3?nz-Jtz z%=M+y*_13gGL}zglcjVT36VC7o3fz@$HG3G2bAF0swNx@QXqq4r8u_hSBWKt#T{=B z3o_2uXsOrYT}4VubQ92&b%#t*k|{TEQK_~qXYD&EN;%vX3rAr_y0cP2rp%O7keSYS z4D-yrwUnPl4apI7F?JUKP&rQ#eijsTre%VS@R&W6M(05vAvMxGjw(rrgJhQASZ(ku z^59X1;emtz@Ki#8ug8@t6_peV5P-n#L;x%m#;Ww193WhCIN7e`a9DZab|oNyJFR6k zGy}Qj!##z8R2etnP)a$fXpOp!4V zWf|NikU5yM3^q+9!LceNGI?-K0}m3E!+dle3U-@RqCUlCPm+Y>6AT|2MJ2-wq^Zqrhe^$ykFfsZHb--k_5zc&0Ys3d2sRRKz_ZBW zcRmxj2-v}-%SPD7&9My^2X75?Hv|Oub}vZ%7KVQU(gExFieF(RtaK0H96os#_3`1H zkIj%?dT91x$Tm^XNiE7oLItTbeJT)m!n|)n92L;%P=YlINk<-}98FOsPl7m4Zt`J? zQE$Z(9D5-WhA@gO7w&DC#k?w6?q)a^s?-hdgf7?ku*GJuOu*3OvODTaWK2YbGhPos zhN2=URroR3$Jufve&H&VhaW)#;Rf^%Ien4C9o?j;#HZ5)aI1d>xR8vqa93hrRA{87 zUjUl1kgINq0!k<#jE2=?3{$(BX2wYkpk|+V{Nyx-tNkNIX{;c2URH4z(KmYOmL* z;JLen1_cSpoxnu;-8%Ikvl5gYnyE~oX-e+*eZ4~{jVR?Y*XQQ}`x4Mv@#jL23 z;p5aCaPG9tWNB_}dp(ZDrIDnJ*p9l9ojkSm!o{p}byK==s~V;s!LwuPQR1TYm|eUy z%5XpDW&IKsZ&5?bPR^!= z?9bfJkhl-@IQzY^eV}2<+1_E?fDlqHQs1 zf@6eZimUQ2y}Gf`D0+7ydi_fP35!4iS3Ws8DCk_nBY6WmR$@`(&^$x?07Fz^BX|I> zlX`Ocg*iFpm6xuyboM`Z`f?g5sV#-Y>dPZ9k6f;iKQQnzCXfDgR2W765W)xf>xiHS zoQS#$xJQm2IZ}SAATA;Hjf?A}3&Ts223~Z}I_IvhRlldTWk#4%ep~rHC+>6jP{f4y zNKQnM*C}aKvUFYLxmS^{2VZ1GbnLkNT4zFGQhK^ptNU4WE+sPbLwa}aGq&%N1Fv)@ z|MS9C>|=fB%pb20g>Kk6D-A#RmNtF&^r5|ry=^o*-bIKy@yagzwKC(rtMQ(4cz4&N z6&G^aRj=$fT^d+d>+^0Xv$5>;A`s^>bN=d?pZ0D(DopPpf}aG7y=VMVZD;mIo%B1HrK_bFf8u`D1DDL@>^n=BeIl^6Z@S3#Eb6H* z_G-Lcv~Tx`%G{*oO*EJGj13jWo9(V`F4w%gNz>XRyr}gj9nUlq1{_vr4xk?6)q9KooAO!cK3RE+`s2LIc8n+aL?&anaW!@^%0f&h3{`Jdnl^6?!No1 z>UEa!pRcUXXE@ck;qG;9_Z%}C@iJI4JNmoR=WucM<($pdbnD)ds8jtdB$GjeW(vGKbox z!Opij`yP=fy734sR9gUAvVA zyPvb~W)@WC8h+On*=G z`RM1TVrE7kmtM&fv_7{V%`a0fZ@427f zRQ}vj;w*L{kHZtm??8z}m`yAk#qhMzgbP8XK@X zHvJrwE1kOA8`~G-D3T<9IQr+ui!8k{eXenTPr*U=@>5$I^y><2rf1p}^Tqj+e5=WG zSkmzI6A6^gUE2B@=e=DXovgoZeL~}?uMMG6xy!HF?al#0II(6qZNEW&ii};CIO2Hk zzNlZt=E`i3> zHR?!V23@W3OWzRM*O*vTaD23X_Ps}S#IxAGuw%24Etf)FyZu6)*CetVUk8tIRbyx^ ziMG^zANMuC!$lANa(_wLk7~YSWp~}G7}GWQi2sDW9>;ggMvU-0g74h@{B-!t@3j*z zBsY$D#M^wjsB*+jk z;j@sKtziaR-W9XcD}0;`x++7*?l_fq59X@74%nD!+7In0OcMGM{6Z2ZhF&o@nR=1C zM1mPnuSWVoq` zZitQdekOPs6mVt4Gy2Ve6Tf*HUTzD%GVtQVjJqWzbMneQb@xHnd&PGiGI9Q4bVt8G zUPZgsx*IK;5Ki83F=)>pU$%@!E3wfCdn>6b=vW&SHXa`Jm+o`1o3QWV)FJPXN%NbW zQ$L3lOm!QW;#mRZfxxn8BKjq zp=ajJ)|vm-@FC;31+)I=U&MU=@tMYziFH3&txBbi@2Pi>8qrzj7F);}wAir2OvUO% zT*LmrqqQcpKK^xM=h&k`1AkkusmZS%laxAfRe^f+u<;>p^@N_<@a6DQM8QCQvbUA`-5fCmInv^P>OWFVJZ1^b%DR|){vyszwa(u5+&Mb)D;++3n-w0Et3> zJ&_`X+hNf^*Sk#d*r&kC8V6@*DD$tL*I7(#6;;$Cb(n$o` z3PDJs!n!3~TD)27ax`;gP8P8Z+oDeFLg#8(#+mVrT97pB8qW*FhiAJI93glBTW#N- z5L?_>COsFquOoOI1*EqqpN?QD43OeY$C5(g@r$Tj2o}~mt89HCI3Eclwi=KMU?~V+ zl@!ux3Zzj~EIFjq2u6`Uav@7$D}31ABy5>!RMoSnCsY=GF=6vU=@5QQg)?j7pMCBdjYVA+7WD&A1OF$SP1kkgIVcT5b zILCYYU0)lXqmz-duK1Q;+bzBJTa&f=!r?X$w8vh#`G~TlaGWOuAr9zV$Tf+7w@BF0o`17X6U;gL2I4uf-pmRE&NU`4`K%iAuT3#b7xk{@L z)f6q&iJg{(oTyjWXbi2}p~M1#J~>U88;Y1-41od!9aJ`nJKLg#Kr^Vy+E&4NC?LJn za&8M7*Fp?NDG;kOBid`g2q6`~$9pJ-0uktdFD#oyw!!A5Q7>Ry)aV`f7F3kXhViWA zU1ks2NxRJ2v~uIg0a%{0gI)Ps6&jZ($fV!2z|xp_KJuS0O(6}yVFihmo(W)q3}vCw z5MWPj(JcVN+dyp_S<5;^%Q{KxVUnJ0@}~9*_}$rKwR5>+uVYZ(Y>>xn(92%0m$P2a zd%fIJy#{$fZUQ&AzGMIW3SEyBha%(O0M@~46VJAh76vCl1X6$^b4@Oc*noJ%V_cHf z`lb4WrRIla6r9%(s(`=%*1!{)BMh6COKP6q^?W$kMMW3#3X6*TGxGfY_g6F(`_uJ*>q9QFnM4gyetpBDNi z@IC}o03msk?Nh^@|Hl_5Rulrsr`DVnK_(S;TG-&Vea!i4x6`i5EhpMhaK@f-$hF*- zZY(h&H-prgfWP36LRO$$AkcgTS{z;i9i@QhqmC-9 z;j1xZh!SX)GU*|Tp+vMzq{$E^0ZNw2_EaEZ(J3ILqH5ry#{g>)hk`{R1hEs%M_Dl~ z5E!;H(FRMEA&zFUI!v(~mVk&Y+{UzM%}h=*)yA;8$yg3=4=F$hfW-3O$!%68`QcdI z6ae&4E^&FPntYUJr|rYcWHQN*3xH9z6geVEoxviJaH%XxM`m)jg*7KCfozMZOd>Ne zDg7i8K9xlc#PJ8HB}QPxB7mz@-On_#W?BGKU~&#TMsVyQ2 zO3<>lt=VW@kw_f~YD997QC%oDi>9E;U{U3W9A#RkZ6S*aXh^4l z1EP!J>;?7xtQfR4QZ_^BqL{MkEhPvc1)&tk7w}^!RuY+{NVGGRKr_f>e;f$)s2XDr ziNtlZ!vhIeDddsx{7g`OuvlIyD93OgmLu3Oy4c6*p-6%fL;@2DD3fqS!gAO>lmKBL zhs~l~#4q+Gkpu9lWOyu#9Dw7KNmy9g!EH_hA{Yz$kPbisW6PV7G(dp>jO8N(cK()V zTu|KAOh7?KT4=9yR5_R{wL&9On6mbODSRCM0Wu`sqBYB^6EBs{Xf?IwBq!NQWv2)> zV6#(_C~rWX*|!z2Qi*=)>~4y0HUyQ4NrKPP?5>0qXe&5o&nA$Wz(+t0Ka(zp z`AAv^IEx&Z_)%~m!2mdwVBq(0B}WuWlG!kTVsycP0ujWj;GPJCy0<;?k<|8c(y}g6 z5F6~Y*7d*);F<;Y6arAC(~J~SN|zHhDSYHnC75+Nq7FIrGa3n;L%0BjS$9LQNp0I86Nq?PalqR_o%YEYzfptJfg+gGnl=%WO5PXPkUg2d#& zLAjQ7R#<%sVftXl8Dv$CFs_A$BvTMqGPx}?c@GE`2tC+uVACuQX&JPYjeG z32j9^0B4bDEQ<q!ZNhbw@xYEVK8d zpG(XV`Z&DkV+){{?4NcHu#I7N5p&bvPys48jI-H$LEbmRj zlLtGGYj&n_h2C;`$N*V541st-2H4vmi<#wuj2&PsP^lSp1uj=Q2XKu*nE;{52$jLc~}KSq8~e~z&Q%4!Qx&7dhdichDCtY4Vwb76*e*O9Df01(mx{r z`fmXHw<&nozeZp=a1Hp|fK)cTs=t~_e@mbb^Dlr5miw>f@3jc~{%wFP4MApbJlE%R z6W<$-v}qo(LN|%;^=&0VC~$CX6*pU?OA!Q9D8;40)GSi?U!MJ5(?6^Gf0mS?W9h>X zl5lWDM%UISI`@8bOo=$u4K_Xvq&K#eT>h29H1=h106inJ$sd z1{D&=caC~Q0I3ElDNr_DF#@VOr-xe18&hL`5SDB>-#*m)cG-fxPoaMxNP27nsQd2@{O&obp1x;$i?-If1(1pHIg1oV2^fk+UU=$2z(<# zlL@{C=})%(33lvuqz6L;s!^1K;+Ae#BKxd#QVkxFLas>wdrt(ygPINQBJ2b}HmL3Y zb?wX)R#FJMCk~%xN?dT!g#1-31XJ z>!VNVUH#vsa%CJA#$TF9yY}YR9F?R@A5c5!Qn&i~>BfOAXCj6S=7zQ1=i{HZ`8GK3 z*Ff|(mbh$8HOjqnI>O~n{r68(cW(6CB-yL;GPac8`ATTof2DF^sBq}8TgjJ_xDn3& zQNv1WId2d1qV<+8nLM=})sfnv&&6Z}*N{3D&nniZJlmUM{o@;Pu)nQGXZf{;r++MM zQ6{uZHKikiXPj$mWmtGi@fw_r`pY`K7? z+Xh*#)$8?L(?r~l9rDB7?YeEY`nj(8imUH-dptl-&234)GW7AD>t2VFA5|x7iL3M} zVT7)k(C`I$(JL((2UM)3IOujGgvR#N4`}yP)A_vXSFbW3O`l4dndoq>pOH*Xzf@Bh ze(F%hfxVMEs_$9O*rgk*2OR0z5uN=o$?;nhxx798m_UV0JZbzX`?%%1TVGvs4u9=) z(#a$?X1K31JeYbgYGEbSw_9&WEnW>p`nrR@s=V7$G0DD?p3au{J#lqZWrK+OIvk)hkiPR6ao2E?oM!X^?@-CPrWJ(g z&=KRhBPBikeatoC<^1_#^YT}_muh32;C&Ng@bY$=5N9Jj6HH9}8Y_NA@Ene{?j0bT~k4 zRdH`AWBFxI+eAXm!8-%?iL@!{^Sv3HmY7b=61JQ>>vF#!q<)3Q{uBhHR2F^nR6?&( zYW@Mmj%bCBFtdt?W#!)~`+`|#idNE5Ws< ze*JJiH=8f@5gNFIZJ}WT>X*;oi|=Lt#A1&F-!4~$Y~6tBx>yzP9~lhlQw zxzL3Zp3fW1`bwb)b>8vi$IEHt563R|(%6n4S3Z};EBiIC<@B{~|5&o>L-xbgcQ(gG zH73Qu$}A;py!jpT^JP5BtTJJUWq5l2tRKB@e?;Ft&4%-$9yhwWh8KFiMST&wwb$)| zw-*yLkTLu=Kc0E*{%p6ZRqMvg zC&G@2+Lw9mF?O-$e{2*J5oFEQ}3>zjhs2IuHm+2$1(dtrD}^=dQo zL4y?$$E0tLT+|ic^08-55YV)6&D!Xu$=w}CQ}Sa6Vd;UDt-LlytV(Rrp`PjO{th&KwgT`Y&v<7%?^SUo@6=u0j zUcO4W?3(pK*Qc}Hz<4N4G3bix=EsNYLW)*Tme=t--s_MwA1h^eH2?qr literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_pierce.ogg b/resources/sfx/hit_pierce.ogg new file mode 100644 index 0000000000000000000000000000000000000000..02dbc25f25a5699dda82ce28695d121035483882 GIT binary patch literal 5126 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_pierce_heavy.ogg b/resources/sfx/hit_pierce_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..9eeeab5bb9d63363fa79a52a0b84fd27c6edef54 GIT binary patch literal 6038 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_slash.ogg b/resources/sfx/hit_slash.ogg new file mode 100644 index 0000000000000000000000000000000000000000..560ad69ef8c94188c1433c038f7f9a81af4e9979 GIT binary patch literal 5081 zcmb_fYgkjq)*db;0Re+Xj4@yW1POvA2p*tPAz+b!6*eLe5j5cuw`{` zw{rHk|M~K{qoJ~+3t{igNfFDJEoClc+SsD%#_$uqmA5S{Ji!@ayiXW4Hj1knrS_HT z*No-1UJbYaKw=RrzmoEDoD}nD!Ug5WS?!EABUUf1!ptqxNp9ap;(0WOWwEa9>CN>A z=m0S#z$d}edc2fhK?>-Me?*1SLuzDayfz(5IgaCb2{}v^s-M6>_4W`wvf`^qP!dz1 zbOM#)m}m16x*ef3M%|Aj)Z3#dDN;XS7HUO~+M7Tv3+>Mis`M~r>`RE6$E2;yaYKGR zY6Uhasy?b+*gTL6!0|GRta}#v1oCMepa6j25>NCvO!R7?bo80&;q`*Pf?)u7sJA*p zygEw~m=h6o%sjc=qNLoi_HgERm%p`NZs198m@vz~vi=y~}+U)8i!0Ki_0Fp|cn5a4O#1#UkhbAojY zSrh{|BUZOtwIuxxV;`N@?^oxFr=>n~elwEtOceqG0Go*pnS0vI@H7FJxY#|uk_x4V zW`XTHADQ8ArxRG`N>e(T;RuWh=;I}-fWU$}@QWc9A*&gcMcgb#8-?G=Y@?>@+CDm- z|Gm>Sao+b%?Pe7@NDM$uR@aiiD6M_-2z zUk~3{7=Bw8w^6xqJwb+F*%ssRE>t8mD=zZ%9PhaQw? z9#t5*v#@Gs$%zBTm%@>h5c*sb|Lz9_Nk045)$wI~H)S9iOvW-gBWMowugI;ua9<;}5P9+pWmm$vmY zSh*D?Y(*|JE1Dtk!!xQSqF$z4T&d((N*aw}@C<-EEEjm1oWRRuYSGdOOgVK2!9-3G z=(4nEA|m@5RWOI;d6=im!a)?#E(;^D7_QzElMQMJX<6^p~A7MyjKI|9_fQc2oM%qedM_G&dIS?4+ z9F)q)rgCanx97Fee1sh(g}@l4K%d1n5{TGr>p~Hyvoyco)kBh=i+IwT@(>}tV2I6T z7K*sB*7BR&I(s-m1z0zcHwf)Lgs#vOoLqv=Q7wb3f~K5s^a2sriY_aH05pZ8N%D|b zx@3sOkg9+oZl_E(6m3!6iq0V+Z4kDosw_=kA@-0wPA19ZS_}@#fL%?3PJ_ed1#XMD+Iy)3CmAww(Kb67MR>Q#(G`HQQ zjJ8BA%>sZcBnc_Kmns(V2xNhXtIv`UdEK7XA}*w%m)AteBo^%O}E?u$? z*7rRcz$HzuWKOmQk$id%z-t340m)*HQ$=|Q+koZms6`VXNVGK!^%zCq!)8nUy_ryg zR;p!ero0rE9|l8K2+J|r$B-y($5efiK@JX6^F zZ9gF@WJMK=`#D?6063+AgP&Pty}1Rz1s=0|auFf)5mF;B z3?4`*08b?p_;g(9voHx)h5}SUFA5M?FjglnsR8Px?+$lqf45ghFGw4R;Z9T7Kr@hQ z5!_RFNR>q^38R#+k2N`R(vCSO>-sDUlt`hG0Be%Ify$6MW)d0`iIQuD(acnGP-P|D zCXhLpvz!=qwT#58lPT4~rMVQ4rV|@zbXUB^>J;Xq7Pv$Ja9Kb@A0CwEqCHsksYdBT z9Ty;E2^QCCJGLW?D}uC_=KH`&?Y&i3nsNd3uS%bdJhK%d6t!37#-x|fD831Jc}Ini@4}T zzzrr{5z016#Wq|Vyfvt92q^IRUXb%S41Wit8`g8n?XVJ_@(ttfJ$xMV@vh3pHb^fL zU9=anO&9mFDvHohK`Lz@iN)(+-nXKTiV*}#utuTjD1?+_DasYfQ0J{%19?)+Td9l` zqlbnemZ>a(dmCo4ut8bk1;;{_=CNSta#LW8)E<@z7@Crp?v^Sg7gga;wE&Qh!+{f; z3nK$Pot9AamSQ|C1ZmW(&_DF_MGtpulVXwxVT<8b{{#pi8OPzS#KWkt84ErFYzv8C zjuQrGV}N7_tOlyR2=yLgHwG0T>PJn%*kVmOI>$!qF(}x&!*5}UyNW#&>HaB zfOZ+Ws*lY%pC#Bw{{leZa=&PPS_{_qr2#|-04Fq_Hs$@Sr*`+-O}D$#e%D;LMZpGC zcyPIBI;rR~fWgG1%p#N;6@~xs?9-b5Ufut*LR!xC?tuisJkk<0R^e$(ZYOs<*WyksV zVO5t5a%*Md6yX$BveA5-YT4533HLsSKjn-IT>;AjOh0GbBB4#WsE}LNproW4d`I$S zds)j%Qo345JOP^_=cIHuj|U44zLgmA>YLvfr!Kwn_8vSt-rP%{{Vjg8Fqb(t#DCs0 zpC_DsMG?$jdWCd9+vdt!YOJ&w7b=AIc&X3q-yTn4Ucs9R;n!_`->SE8$9_i&6{vw` z1AUM=>$^Ef)BqFKYW-%=BzG;_1-7s!r-6YGTrU zLE!%MqWxYUt|%|oCO+S{NqMB<r~Eu(M9_Er7sWv(&AtRIJA1!#3O~vwDO_=~D=K(0$-uX>-u(r~BmYQ^ z)FQV$@TJL}L>uRI3Wr3lle67H^W@?8J2!1j4pS#~4EUHE9o?R?ooq^FUm|N=xSaFE z{#mcTRkceo=y1QB8vZl@xLmEvm_ZzRj@-*0O+0$jHD%T>&V*k@RX?4LDLodla>tcf z!^eJ@@GW>_e>wG{>`nJb;6&uMEddu||H$>==@;C1Z~CL1=j;xrpcUQHlvM=c@6CT* ze%ax5?pUn!w+uDH8c`oPc&yU#;#u}rKlZr?#Kt`T#qVrNPybYj-u9LwlZJ^)OaQxo z-#7kc%Ts2>-=!~bczr(V)+=&oYE{fb)@ZotbpE*|E%gS|l~-dpe?9jq)?)7r>HF8J z$T!0SkrSo|`Xtj6hVE_-dk&n9p6$|pic-XR(w@_Dx9vhn`~KwWe_O>*P5VC&DNUW2 zdH1sEgZury-5!1qckX`{ye?*M%aec~ncFN|%Np%6e{FaAt&WZZ2r5<1PDJMj37v09j? z#b({oNKYDrJpAI3Ve;2!F0{38O+IvT!^syRGdDa8l7B-kc=O9o?Cm_$)*>CKir#(j z*3%<}mJjlt|2ALi@kmPdRGr1-y~F+OvVgDUmW}a^Z5>II5qt%I`j0S!6Ucc^>iI2Us%|5 zj*&gm`qQ#Cd26>UGynShu1t+}u4%m+_HBE0T6|g?iJU7DmOW~;OFR*Ewx@e}R7&&@ zc8^BR?Z&m_Fs8STcOF`?`yJvu%NO`VB|U0*+cQptHa zGj{9Zw8M1%9LfmNZx$O^Eo@ZDQl& zQyCm$#9iYKh5BCu(`yF0d8cjPZQj1linrXV5hf|zv`6P}_0eCrwo2h4y#0JC{y=u}p&H}RHJPkoCD;@=yw6n`!mn21mV7w5Za0`O|3<|cJN`MChT>63z- zlUY$K8|#idnU4PX1~=>c@&g?=qvuUT`F!nT(#2~@Dqgp7>GUe2*}TcO!@KzNPM8nQ z*u8pv@y(At`bLijy-#)1E)AW|I^)@SY-K(@;QgW@J?(q8f296Ms?9H6bf~ZLz~uWo zK5tXPr>lvSLoXw6YWbRACgPpB0iaN`EYyKd&suz^(+i*RyH2(;n_WNKT$k~8#Xh+j tG@iP9z1Xd_j=ziLU>tbg+~?WZ`>&%%SB#EcZMrtO=w{NlGtQdJ{tKUSadH3v literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_slash_heavy.ogg b/resources/sfx/hit_slash_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..7f4ad3a782cc331e2179f6413de24c7c0e2ed6d5 GIT binary patch literal 6150 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 s9-4XR(6ZY_2VRcjTtma)|FgQauZk0GPx# literal 0 HcmV?d00001 diff --git a/resources/sfx/impact_blaster.ogg b/resources/sfx/impact_blaster.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b98a21ee0932d3e0312e4d4c029b785709303fcf GIT binary patch literal 4964 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 ac7}(h{U^}Ab8y*!c;N1WoJyjj7W!|tF*#=d literal 0 HcmV?d00001 diff --git a/resources/sfx/impact_boomerang.ogg b/resources/sfx/impact_boomerang.ogg new file mode 100644 index 0000000000000000000000000000000000000000..79bfe33ec5752dba8b177a74f46da9491a3d81b8 GIT binary patch literal 5756 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 HUETf*4t>i@ literal 0 HcmV?d00001 diff --git a/resources/sfx/impact_bullet.ogg b/resources/sfx/impact_bullet.ogg new file mode 100644 index 0000000000000000000000000000000000000000..db4b3162ffbed00d46749a375b9e34e5bd455951 GIT binary patch literal 4810 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/impact_mine.ogg b/resources/sfx/impact_mine.ogg new file mode 100644 index 0000000000000000000000000000000000000000..5b484a56b5e9d78f8c10840628153ec9d253bb4f GIT binary patch literal 5191 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/jump.ogg b/resources/sfx/jump.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8c7015eb502fa0f92e6c23c3753bf6a957086965 GIT binary patch literal 5284 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 IMv{g757B)v;Q#;t literal 0 HcmV?d00001 diff --git a/resources/sfx/land.ogg b/resources/sfx/land.ogg new file mode 100644 index 0000000000000000000000000000000000000000..766673cf8a545903ce84064f39796221dabdf425 GIT binary patch literal 4792 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/latch.ogg b/resources/sfx/latch.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d0e39da8120aef398fe4f32488a4eb0a18414830 GIT binary patch literal 6363 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 G2Ko;N`Y*@; literal 0 HcmV?d00001 diff --git a/resources/sfx/level_down.ogg b/resources/sfx/level_down.ogg new file mode 100644 index 0000000000000000000000000000000000000000..c1c5de0a845cd47659fe33b06e1876a40e154292 GIT binary patch literal 6701 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 uHY0Xl(>&L9$O{GOBux^-k#xZfiAmDr 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 Jo+P|<{Rfh*U&R0b literal 0 HcmV?d00001 diff --git a/resources/sfx/poke.ogg b/resources/sfx/poke.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3b5c0d10a07fe86d0950f95ecbd4b14a03671fe6 GIT binary patch literal 5384 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/poke_heavy.ogg b/resources/sfx/poke_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..6a83ee76f0ad3c74f2208a15075d44912d16731f GIT binary patch literal 6410 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℘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 EAB81cS^xk5 literal 0 HcmV?d00001 diff --git a/resources/sfx/punch.ogg b/resources/sfx/punch.ogg new file mode 100644 index 0000000000000000000000000000000000000000..61a23e44363713ed7cfb5c59b9feb33d83a090cb GIT binary patch literal 4808 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 Y>-lpwm5B#iPBWI?8k@T_)=v9>0A487Qvd(} literal 0 HcmV?d00001 diff --git a/resources/sfx/ready.ogg b/resources/sfx/ready.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b4a65f0261a517d4aeac7d75e4041a6a69408bc7 GIT binary patch literal 5469 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

n!=<5Lp)nrHo(3OhK;w7TX`8ppP@etrS= zj!!R=<#r(VGqdnB!}MQoucI;LTD$z{TUt@m=?1Nz(UII{iM>ox@#m5Rd@FJj z6MT)+AMg4MR_t!nUIqecR*;2KSMOFNhp2d}#!O41HfDjfhk)>)W`nZ`I}4BvYWsg3 zJBx&s6oT%F!8_gN;andI&uwhbK48geYK<9ntKmzsZg4Dkb5aejSDf#cI`FHAu#`b6 z5cHDGVo~%}iQQIV#;INCSC|gWMq1X%fs>V4_*DCDb()mss;a~N_)|*ds${7GvQ&6A z;dBQ6GG6;RS&}0TGDjXlqcPAu$m+f{WMFEEBFMi+qN77aA!(Z~$9=9y8CgTb6k;P$ z7qWA7WoxP1dyJ^0sMF zj7=a$s9*_Af#akmVq;lBLCw`0_qzIrr(Xa18In{`0mbUCOGE@dT*EIg;4mg4e03Ci z3fzP*_|*|cCDb=o$el{QQhKSdDEC}$QFhMRR2!$NGEAk5XwSDl>ymeFyKX*uYU1P2 z-puqk73}ies;h@vN}uIDD+_9H=!{<&kF_bxH6GfWdE(nKj?2!3lkfi!zK5P4F*n|B zH0Np2_Ru%j=?TsGm!&n2ykZyEIb_zY`EH$ZpgG8sg5EG@QC4U1<;K$wqqdpF1|v5o zmcL0(zh5X9JLYC$+r9nPvq<~ggVpI98Ik*|;BuC?4a`g`yzolmhj zg5FW}Eyk4>U&%b$I_r;)YvJw?96f3+T~jH0xcGyAckYBk6KN?<$^3i!pzTtHfZ}DNX87`~tRGR7~@sZ|1z)-PQfGX0m zGugZw7c|}QBK-)#w791q^R@MrVB%a;$&+E_O_lMart$iT+>Dnm%s2E^%iL}fJ$(1% z^_qap+*9TSUxFV-=d@Q3-;S6orCwI%YvjC~&uy+U`c61%Z0@}(YS=Yn`t)ui`{{&? zwOwZ(FTeY=chbZ0G*$b|J%38D{tl8l0P0)`}N`z8?DI{ti-No))V%1$Mf-X&g;)KKB=NdHU6ZA z9+x}bbN)zgak*n`Z!gDqRpx!`s}AL!oQ#ya1JR!bU6NWnTAJtA$dNS6mtW=1DeN8EX{l-i#J6>H0GHirJhng*H7#E#r*4=$b=uQb<;KhBlk z^|s(HUO`*mBhd4`{J};+Wx#~K@0MytK=?Vu3%lbZmt86jaL#NYeTkdupZNCa%^@ST zNcz+m7ANdC>cYa%1ZWk$^){!vtW`Tjdhp%I(z~v!n*fitJX=<8x8FVJp9{FWVJVj1o=qw_7Xekp0g$d=c=wmJ8; zW^?h=UzWZOD0b8)Gon+|cPuVC8%qjaFIf8}EI6CSS#wTD75cL#cqX<*iyt-I0|IQF znz!CqB<|^$zC${OlZ_$APZQ5ON-f{0MS?#Q{4LpS1dlUszW>fYTd?X9Cihs&XuR+3 z2H7a}dXEh=!`ZUkTAwrUA zNMIZ%dfOY#FhkyFmOlb#UAw=lFQ*OD=b)kMpuX?7c9+O!htgjM1pm5CapvrUu}S7Q zY0&Zt4rLIwSv7A&@4n{=*?vTMvNl4aKo12$Hw-;TLVN31)4KWXX)}LJjhvWU(iti5 zoGW-;b7bb@^?8+qi`O`RxE7|ZLbUnj-fY!M4r=&rF_dC}Z{kInwz))8i>B_lmRW4Q z-u&x(lNOFwf6v5VT7fa0#cIB=XD^bAQ`EH$v#DEt?!Bel+?{2Tz--jjV_FRs8d*?E zE5k2s;?KTB9=xVhZFNK$vE$4VSh-CXOGB!ow_ob&7~a+vP6Z+W)vXA!$4|refol zH90RPviIA%5$B|U+!6PHpK06n1&j?*^$MSrHOpQ) z_onb`|H7LY=tQa>m{8Gnu`tQmcfVv21b;~HQ)cEH5JtS!aIIK7ajK>tn{0#Ncg-yRNIz!w(Tsf z;x`r3eKwvYi#iaGw~TMwtUlV58L}oeC67gXLw3O@H_njAgN@S{{r%jJr`y#(7FfjXwO4<7m9X!dhdFmi z%VSrr6xZ>VN@tpj{sK{?{k75KL;SS%E4jfxu|*OlDRGRfp)22?J84}@zu9@K`0U*e z`$8*Pd&+lC{yy)>-MTQ^wEF9$>5kLq$DYp)uFbmkeg9bB9@Qf?8D`tE_m+IU9NAIv z#d);lyh~-0L@`PX5H_k+QggkdE7@fCNtHLg1@BF2&MWUIGFNq2etKTXJ>*IvK5D(m zHWRsb$9=<}$xIyBbA7|+Q!W#3I~s20te5@t#nzF@Is3`;k}1%{nPJtAfOQ0BqSKAS@AeUklu@qc0Fw%4TM#8|iB zS-Y0T)6Gf{GS<3@mr@|(rN46DvvIIf7C^2${QVL<^S$nr*kjFQ||dv zPQuPPJM&W~CL1)I_RHktyxjIqdFGLzE8Jyt2tvpk-v zvu{2(Ip+2B0S9|hCFMOO-G)oS#B;V!)iwAZY^gTXHeSr3Eo6>$J2zca691|tvaI}s zc@EN*?v3u~Wf__`eh8bJx-Uk^KSsE;e2aI&Cho*SR+P3@`<-Xp^BUJD>gt^8^Ly^} z6lEW#=lT0YcLtnR34ipR;n&(YQgN-oBi_}g*&u)PtQ7ZbT61Vi{JsM_-lgoHkV#2W zPVcM9n)9leSufJUR^B@rEsavuX_Ogx8{@&7eJh!F=~jjGvQ~Jv_1m`G0j0PKroy#l zp6}iFe~#bp#hl$Hd+@~fs;yx~48F`(V#XTfJr>Iimm(?gtyQLG&Nf^@pifmNYWnSd zCkLwD8Ot?4pP3LpceQ%XMntadv!YXay_Ff#n!I$kZvK4|<#Ry^dcAeS@bG8fGoL@q XJ%tiG2#SL)w+P>_?B(h?2O<6gJEV`@Qzsd%f4%YwvN~%gYMlgZ_G= zLt7(YF`d z*X)P@_~^+nPc02qoGMOD9afhEe;)pnGwvQi`T(Q+jiLR#-PT4Sep~xiBpBKvphFOZ zB1kCz#-b$ac5ITTNf+i5IH3qu_cBylKE$~aZZtVZ_nQ$~m=1Y@ z)QgpTP#gpa06h`o8Y%NR#V23%o_zg(-?eGc5CmP=<&NV2I|N918EL~@(gr8t%>-FV z!)=5f!!l<48|?2GN}pAw0a8ACR)67D)a+UaBp~Rdly<`Tc15H-)lF)rQD89|NFNpL zJ0r#wkt6XEgu6LWj~l=U9u>gH2WYAU0qB4aEQ3xm!4_w_C1TrUgS&9;=y(zJ@%;2- zx{nxX$8kS*WW!} z40t$ac#N=5J9C|#2Sfk+<+&aw0L3ML0IY-8B$;6%%nMEm0+N8@inJ5on}B#EV;s_y zyfeHaGD;${OE1guR6t+=Yv741oRYMZ6X{ct7y3=pp;nc zeON+DQ8uw71()c9Wm+MzWlVZ6j>9PCl9ictav*q;kP#>sNO_I~B>}fiK3)RHL5D~v zaAc_>gmv`VXv zQ-bo$G?E{NIYhv+)_@_mE;f6}XIEJpJO>lyNMqLz`Fvv3Z6;j?%XLet;d05W0y@f! zH#rT*ed$)I0{~MDCykV(Qtt#NFX0BARsld4p5qk7VvqHcS!HZ80NQ1OE7_y341nhw z9KjsGlnRGzh^^sG-o~5UK6n>w>aWQ;sNiF)0RZ6Hw!Xj=%J!1e` z5Lgz(F9r@uCHi?@^(lktgB_=mWSG3TmT3}oKwL?r&YW}$5GoLQu;0M4S@9q{uI}Ftc{gf zzem#@1EmN;htRFyEYi6|cY`kih9K!OVYX3gY=gytTf^E70S5lL7bO1^hW`Yl2h?-r z08j~US-J;bNX_T@_;$_5c0e!5C-VYe8^h=&6lKDp0#vGxGZ@w&@7rKU86*;rK#hXa zkp?K|rKlT?4Lh&WW=~=9yk)UbzM^m#{BYcCu(v@L(`vZcW?(E(X&7|^F4x=pvhbix zfY4<7_B5Ap-C&jAnPvz|7Zij_1-5$Go9gbC6;=RCU`f7HGtj+n*y=rH8JoU ze*q-oe?|cG-vIU>Q}D2VjleSD8t{(+p#peSe>FG!BY{55zW@?g?!TIUuZ7q5Zv!M@ z2-1b)saDuW_@w#1b4P%Y{6hijQyoMI4GykD0=jE-NrGVVq=ZbEnl%dl%d@}N^q`1*A8&gHa3aU!oKVf?zIH%BK`ld=ZC~ z4}sIF-adF;0RvP>6^?y;w?0TUP)UKZ&X)I}sx$lDD%fMPv>9H>hVyNv`Bbkd*!$$* z8GS)lJy0G%`soX9r>PavGu$d`xU$ibM^KhTGZjvDRChCqLM383Vs zJ&Rw=kHWPechJDcNbu9ci(tndLAg>9P=llxl(7ED29lSwtxVv1A(TQ2*n0>F2WmFB zi!f3E*`T)n*R?Z;S4ko0fdG8c?H;=3KxSLM>^F7!X7FaCuA7GX2a>X9kivMXj8Dym zPrbtapZR#D43dVR4-7h;tRh3`H1NhJbf7;fwkax5QZDpeC`rL3n09WX2+RLg7S@aV zU8+ciB%DPO_9@r7n2amM?R-lTVy*+3BLSh&iqHed;7<`qP5Tf^M`9d_4miaRi5PVp zJz;?q6;nsNK`0QEA(NvPbkz_8q$DE=og}E5b9b&)bZ5w^c;59d4vG_p;;4KH37(!+ zE${et7*gVk9=_*SG;&wuDI9{{fa|0@LZL7#tK!z(2OYgbZ^l0_LPC<#pjiF&@bSTy zYxoBYxQy}femV-gQQ~z$@J~k=EoGaX+VXos?B&>s{L2A}(BXQTZ+xOw|8&GB`1E0fJ_?kf#-7jNi)^@q(L9rjrmv~6-7ta{$9rX*87+t3a6iY zcX)2DILUaT2;YT$Cv>b%F0IwHiB+IjHj^f{^s_VI+bkX^2O04~yXwQxLU ze&~f=*=g6O+-2SS)ig@Kna#`oE;rm6; z+wWved|e;n72@=~HKTjDyf8D|f~!*2*gnzZYZf z9sV_Q_H#v4xvdQyFP_ozXne3BSH+#(U$4rH>?w8Z%u??E7qhInH0v^gXsE0E<4)#-PW1e*0oqnX{dGq?sjJ6$) zZ8wFPY6xjg^3>wwh#Y&wDk=iAGFG(w&hJF}ZB07|HoN4$(J3{V7-*^7UG6vN#PWd~ z<+dKRN8PZLjmKuU>us%cHelzqcPp&U8V)@5FFE(4o9XjKe5Al)$Il-TLM6NTlM8Nad6{mPsp#PhR*0#%TTJyMEeI;*$vHlm*!u>{q?!KJD#^(KV zZ->u(=`(2ZzUty>^5m19it$w(l077~L)=@rY1fhUPg@VUj(!;InA7KSIsW2Jg_WwE zLz@y~8d6F8=X_e7_$8o#UojA*lzBVftahg!sc4vZcuN?1XrTCZ{{7^YzESSG*ZnT| zyq%98Y7Nn*OSMjoJmc^5b@rdEN_~+ot1T|LGV^O;vLlI9L_hY`G_S6jesXN2C@0GN znaiY=ANf1UH7RpM+?6;{pLKkF@c}d4&0oya9J%HVsvElTA--ZUU3*Kga>NS(p+#Ze zZkKvkeDvgmB6Juo)5vnEt~AdUHhREjM(he>>Nufc)hf(#D4)pKq4>;-$|wRNi-_ zw#?OXbgN(SO@HgDyQHFT+5Ooi){l;TuXgDOt-bJ?!$8v8;-5B_FI6`7eePB+J@;`^ z_2&<(U#s-tore!5xP>>$@N2(t^X_#iJKyq0L~oU|GRk>gnhGFVwtTFy_>&&Kx; zSddTNvv!n9B977oQXTZn_4*>Ki=4_|kM7M2F1XdMIpF*H*JC|Cm&7{y`a}Hw)viVf ziU_YiTLz4MC?f?gtc#A{)X9nbI`&C~NF^HtzbV93ogs=*qrCT+W|mqTSE1)mGonjfxHy+@fp$1oPXaF|hj z;>H2|(6d03Fn77?FOpOCFNt4>rt$V2IjR{hw`_?^<#i`#5LMNik}W@vf0j0lE-;eb zqm`c(dDz@(Es;u&l=xv@vH3@pJ*IeSk}OT>`&F z-1LgB2hw{&u}fQ{7CYZ37n7;f`PpA!bXuhrQ8oWS^GcO(d+n%f^w*itj@D^^v0~j& z)dABpmoN5H-+sN;5S^`Qn%QZ3=#+X*Ek_}CTip@VjsHtJ0G_-wD+_{1h_=@qaf(= ze0;kLG15ke^+MWev+Pu-%FRpnvV(F&GiX+^%UV_Y4af-hNv7U}T-CT+-+}oMdlOl{Vz9cIVd40p-gLZO-+!N3=`)($SYU z^5yX5Ww$;+96G=IlB@JXz6S!eLgkF5vc8Qgg_+8aIiEfc%)3zo**#I~%l4!ExJ_WYB~WvKAN-aGubsvy5O$KJS+0nkB0*7PgiTqaQ|$zT_VWwZUhv8Wjp3mrGTf z8citfIyXs;s9dUlB-rW_*LsuI6df;r+Ee8$%`(bbz~$8xccbCNi?BugC%uqWwNa!Zhk)qEpl^kf19Ep>bgCDE?1)(omKL)?cd2(MqBnXZ2c}i4O8rP)sgX> zc)S$<1}QJCc)_!`GyC^T#-W1=>m+?sMV*S|gyp*ripkWZ#2vZ&vuB^~#tBdM=LA>o zSk|Y@8`HiC+r?kJ?ap~q^{OjdO+LZ1l|$b{MT`vK?~ceVE0Y9YEj|Oc{g znw=i&x4SRd`efy$qBN35sl~oe3MtzarjHD(&?@H5zSeFT>7MOjR_woO+i%@+hP8Zh zm=aBh+jiEgS1}B$zHfiIRXASOm$Rs0wYMS`Up6F(fbtUfLAJl}r(|Hd_Hd4#Xrnne zm)r=B?YUNbTBGi`f2URwn`HD}w@)Z|P;c5#!_~frcF)!5%aaYq2bzZts%BS&4;cpuPJ#Ay2ElI)==!?1g-e`J#$EB$M}E1}A{1F`B!R68S?S<~ss(k9R>aF@hV9huvD* zdQW6FY_@vZfBzhP@92AnlD^X4c|4R~`~NLvDI!Tj$j;bm2obU`W6RiOiLqplLD`~EVk|NCeHr_Z7_z2h>^no& zB1D$Tn!@)UJ)h@!zP~?yuiqcP`*qJf*E!d6&h@^obDcBy=s7wX0H=U|T~3<|EhKVu zuqc3xoy^z6%hm}+>LBAXCVc@w-2C6~5;8rK=D#nJCK=fJZ77>VCx86k*C+D7c4$Dh zwvDfYq_`+d6n0&VB+d?g?A?(bcJ^L!AP@NukHgi;_OBM%nZMsWO3KD$fC2zOl;{P| z;E*vgotHS#%y~IV-Ovs;r9sYo9_0ubfMt3!s#tr5cxb6;&CY;f0H{DYjb{ZKkyz6h z2d6AP|2%=TJi)TpPTjACdtQHQ5Is@X0DwFCjO`B?DNpq50D$VAKxF>Sm@lUBShFn3 zuxI6`fDiyU4a(7|S261R;2HQSJFxYC-hYSX007{rfE|SVpAb-SvM?)WA{Z>rGAXfM zP-au=S1!SZeu9ScB8Lsi6{$D_H|6$b12+GLfF1yB7;i>A>ENN_46|kASMe<50I5YD z({8`7JXABG^h$3s0($B}jT0z9A6q%X=#@Yj@BuBrAT*$bDYlW&4pvkztb-$zM(k@& zg04(gLAH!Uc+FG|;YEWJ2Qb#0*rWA{2>+5g`A0aG$^cI!sT&Y!?SkFpTlU zs26CD^{-nXle7$2RS`VuUOejYJYDg^nhCN!)udZzR7K~K(U_6j_^zAvuG`$0{oJno z)R?_xlKl+c&5~$oIqvhn?-SQUP6Ht^UqIBsTO+1GZAcASC~0?a~%U_jKsDR3g-%&Tq+pkpMi!QomO7^uI{n}8Q@NaF z06+}@Y#pAg>Yl6}o+PhM5au8UH~>IdpH4=7QK7b})Ds02Q!YP;nCIkr6No&QN+7 zfy0}gi@+L2qeQ`pVT5;agfS>-zC_X}QG#3qb*>$~5|lF$4CyIW@FN@s&gC@Vc?zIX z>gObIsGt?8cncVnG@!OLN=q4-UTG*6LsWv6sG=1+GZNyZ`FSzJ2q+eR2kvsh029ku z2iML5cM`!2n}du|^JpBNg`2=(*RR=?k$`|Z;XuX`YvOq&I9nJ74i`znnD=HR3@fT* zL*o#dytVNNG;iVr91csu*t&`kCT+_lK#ji)ky@6!XbE++B4`R697`Ib1P{&%G$ljH zn~1R$=EbLg4A2xMCpI48%8Q*)g5v%%CTx50_z7o$l6KMeCo^WAtx!T4sPx0AQ^Pu?)v*cjn$)?8L@{V?V!ZhmFwgY6i!n(`PN@aM=J#^};1xL~_&Jw5 z7?%hrfZOboI0PE>5rl@2fjSSxLO?K!Ajn>DFET*GmcR{(!~l0C662rk>b%kkMVU)t zaD)w#81zbDtcqL70FG9*mwnV~PtVetQ3D0wO6$B1ngOxKfNP2hgi4?tasnyp{7KWC z0P$uZp?+RTfCMC(E$pNTxdjS?$iaL`F~JgTg->9H5zR@$L~xma$bmU)?gB5tLy+Zo z;whl@A}bJb*432lbHRP#S0_GF0Il=@I4ywuJh)NvVV;~+pAr&$B*()LEZCE{mfV0# zgK>~D9nUJC2oGA3y)~u>*t`;hgdm zM4Pu@P$@r!6eelN>ETAV3PNQKWQ4gOH!3s4W^#a$cm}6p1_W7Kewp z(2~O73L~b0Ya7gBbQLj83)BTg>X$4)mupR3a1vmd07H}J(%)P}v?Yn4Hktt-fszs^ zK7GZ}R8vNXl~(iwhbuq`#|O|q((X&z+)ozi357wx3&5rR4WnhL$roh;qGpMv-($);olV4$NOIl1UTLQYW_2qlfM5pfH(^PGNgE3&l^@+yRp#C z{Xm7Y?X=yx^q<-Nf2J4#pVZF)M9yY` zMo7~!JpWzzv+~oxFu3rMV0uG43#!5M7cG)ri83>c(Yctn@EMGX)0>o5wWcVF+ybyd ziV#dg`{ck>11l*gEPZ(atm@cN+cNwzD|+LkWRvo3qxt@zCb;&^Q5$lU*0NxE0Mk#7 z@+$gz9wy1Qyo$&gctH)K57!bQqy_XfLy%!`D8W3Szkb;QeL)_}gsiCFV-FO4zdsM| z9eeYkbn;X-=s4KY1nPV9Wh9#JeWwLV^gU!D>iYYA4p&?~r8OF~hYZ}5*v|=oy{FmIBNidVqt22WSNp-_ZcqZz@2f>AzBOJh)E|(5Q53-@8LadtQv} z6B(D1AfTar2P5jONOhqgiX)0rG~>;W548N=_d`#Pe<45!9S{dehoDIPvN0_kDo+0y9{w{&k%?MLJxQe!zf- zu~R2M9Z!E%Iq3pOKOIS2XcR<=@5QsA=V4{o+=A@9+`OWk?2N3u?6mBRjQoO(jP#7m z9hXg%L5Al0e~@>8VWww8xlmziCNiYdG2;S<~sg*AD7)uyQ8+aFBhHG}Rn|^CZFvqn^&see7<=ygb_x|)u^{ZVIK zayKb6DaJbOMOSxUwEM>N_Lb*kf%=D^_GJ^8ikH{R;rm~qsx&DnkMvBuM(+p*u< zO563WcBsI^_G|&Zce97x-^s1kM{}kIi(Qh;JrpOq6Q}wf20M7>I?k>i*G+UPVA>`7_Rp-o{ zC(Yo8dEMOe%70V+;y{b*H8O7_3PYb`sieXo4*au%gVr%;*WfQL!QX3-ZW?2n z%O)pX0)*?w7V-W;Bd#p4W_{NO51waL`k#|JKFEpO7T-f{1+M>Qe>*MH_O+f!cNFat zty+{Yf9b71VWTk!Vsby{*Ct70b!*y}B1V*o+;i5T_f?#00M5X1n2N?}rk2Z|r_c;ap4E5~swD zOB*(R1Iu~yh-f@@*d1DiGqZXTAyINofE}U&$OMO-0ACVV5EQ~L+=lZ87iy7NiJ4q`9)g22%=9SC8 zXJl3o&92zSytsL;Y`?jwe5WhDSy8OC@g?(U?>du6%}#u_yn0p^!nHKhD~J7&&HK-( zgV{~*B+K8YF`WDOQt}ZMmk5)g2yMy!5$(q*6Z7Cr@~pDOh#Lw|w-dK|R;vK|R2}+m zx1Ft)SI)TXobvh|qOscs=fEiEs3aRo3ZB@}jA>~oMp@t4j>wenGc*_bYJWvI;d0ZD zp|XI=Gea54FEXmkzeB%d)y`oZv<9ZRbCbn=RrM1DgB7$aP{z+ZMON6qD)ZG9Icm#D zML&2vW@;Syq)7kU?3Pqhliee$2HffeY=heR6-=}AjCWT@XxnH2k^FH~Vv3XknMFN& zAnQ3`$n@S5E&g^(pZN8=vjwpeCCZ!iN8pPwmY-XXX9BUuu7W<=$Y}m(O^tr<)$UXS zN}&zi^hlOfE&g}x5?6YyLX3+g$+8`#`ID@DS0pE1=zU#pS>+$!NOp34>a;d-zEJeW zHIvV50bWD=8M9ivA6K99g)&O`@oA+~HDdp(moYF z4#l6{eT#Lp91obZqw99!*Q%7*2GkNfOJBP@9@-9MpReky_ZNRJS&|ZTNnPZf#!S02 zvt6;VM4PX^Pd{&&4l9JR21M?XA65F#-YS0{bF;}SI>G00YR_bGfuDG2 zT7HoI`%yZ-Iz!y1hA<=B74neO6DCekJ^t*u@VQri)yRJc5u@=Wf93M2Op?vRc){1)hgE zLeDutnynID>5)6^bUz@j7mC~mAJ%Rnwh#LfJTnq1e5Mt<5GyfY{F58?g+4b8c^3hukKwLvoT4mGjy#f$1T`7DfT%QLcgRqyye2Z@YR z%9Smzy3cGMJ~HYPMhS-xl?{K-i%>b!nj~%0f62Flm|o`TOa|9h46E|?8@mzG5Onoj z=XA`h_aUw0hCxZxhvYSP%WkB|`vV8KDWRQGJ~C4v3ygI7mNKsdfKE;QRfI44BHK+F z?v-;$OflU>#nzU}7Q7K-;k(;dvhiDX)t%^I$UEknv@uXK{m0b=C7n2ng7zKb$1}e+ zV9b)3`yBi$&{(6l1w*5^t=q#wM-$&H4W4BmG%p{V_jeXasPJaE&^mUje@=dGt?gR% zBLzXX^h-c~ukL#Mtx$@>k^SOTfv@)lRUCrmlQ6)nGY)eKP?)>Q=Mbjg`fYI5fl8oOps>2?N!J4s%7`q$L0ImPFd+o&%`6V`I9FEX~ zS|bf97vL9@&igZ~T_q&-Q~5aMQc)+rA-v=7tNE1lYJ%L#Jd=eM2odVGW&v7LX~hSk zuGNhcaCwZ~t+-pO%T*8-cf@{h34&)&}d#>Y45@I=}YM<#A>MkB~U8|(o?4KEFs?l{<8n|W>anY zPw0;+sva44lEq7eHrw2KAg=A@?mSw|C`R!MOAiy5#&zVL`1I1Fg76P)WwrVI5_=)i zTU^NTjMAE?Zs`fZJC6<6<5O>?2bUEZNMf^*eRHka^(ZEhP<|~d)JmcmMpvcAKO?Jx zNG`)C*l8vX^}N|)jktZyK$pJ^{{F$8u1{NSdJe5Lt3z!p-efIimf#DZ2iUq(oEmXx z<_k;L4-dI_}nH!zEbB{|E~eejd}IXd7c3SnaS6!aQgc76De8C z8}(fTOP9CMgx}$)&pEzVgZxGs8d)h43H_*%jn9A9lyc(t&*}+(zHX(~X`Gd|4xI-S z8jdOHQ_q}h@zPz9$oq}1$ji2}stwo)2rx^wdDwp$Ci*!>BhNXufu3E(bxF2rKY`6L z(63UuKErr$nvd-CVSZGAmAck_esTPw-Lh(SspGEMugy!PF_Y@&9s7iO3aiGtRv*;N zrU_?NaX3$0K^s5I?z=5!1lj6?DM`}&8J=!7@xVb@vXw_Jmq@&S$s}(??{?nw*15-m zxDaySRi;Nk)sCGmeE5=sO2m?32BlMl+a^Uoa?3shCN%2OP}fc_rU06-_TA z-6r37$gG=;u==YE*>@+p9WipLaDn_HFS4tgV)!8+&`G0<4mGdm4J4PWmJ23s$40mPsMDk z@R`O&ti(^SJetUJsZCT%y|Ic(jP6Tz*mnIi&Y9qb=--Z18oVO$V6)QFhyQ@Ar4L$7 z8*Y|g<5ZD-uY{`R*l|JD{O!I^;BYl!Fi~nc*P`!5l)<~$P4=`-51aMogTCOS->TMt zybSIMn0f7I>OxL;VJ_I18FkoCEw+D*%1u;hY;c~P7N_voO3@T;T0UPf;j6Rrcuy(d z&SiWLD^fM8ASm?BPH^H#!7t_BnVkadG6rmflKud%6Q`N;e&I*iK)CPo6~WhRYxYI_@_|2d;7M>3|k%A~6vzAo4iFtMk zb{5^y20?ba- z@oqvU9hW<1yw@gh`tLoOZxfNJU-eL;l~aTSC+wr-Ds8q`nM+T$KI#8#d3dAX%ard| z;7A(B%y|khX=)S>v3aKhLCyxR9dM0!s4sZ(OF3UAE+rcQ;g zQ;CZedwBy3@VI0~49HQ~y;M1Hk8C<6)z45zv{*VXE_?UeV?EU|+arNrw{#4y_P03F zB*_)~>A8(xb1ky@8GmiAW?I_Pn(%)07mwEXhr!w1pDIE(@{``eXEDnMHsz7wt}Jzx zY5Csm9&B?OylK%Cyv?yJMzW*!b%`#qQse7z-jce&<0W+b2!jyhAuU6lw<`0SomDB^ z*6v0sOxJ-vUEedPzB^-Fm&{6L{nk7Hq;HN^QjMhAUw@YxUA11LS1o3^d-Wl1SF9Lu zWn|AzIW?Kj_*@r9ErDM7zMi(N0^B1*w=#rcA>Q?tl#80L@TzGcMz73ONONqcp|a33 zJJ;qEj&9?l6_*-Y)+J}r#s=CW==v>N05I8AJ*99L@yG)y53acadp{X(nAkT z^NRoXX!=X--$FFAzyCzmtuH!VG!gr)`Q16YkhnW`V{fS_R=TpM6)m#bJlDGXRI>G3 zY@DoFVyCujNVH>ga4P;6>#nt|+;g?(ic7Nx{MUl)2wsAIIubh5 zn4y;OKbB+nSXjz>Vhi3`x`e_43)kO9QxH~WP*O1k5sV7J~UE&hlS8e1<-Wf+!| zb#M8VQ&PuWQe>dl+*qHM#hxNkYvNUXwr*UmR?a68>fjQ!%JYlsx5AaPUy_RDEvh%w z*!3r^tAE&M2a3Cjnx|2?=~q2rCKET5Y*HNYbF?O6`ez#u%M+=$38>&cZQ68}Pt#fD zWtjm8mm0SU&S4#da*T3yQC$Kndwn_h1`2K2!IMbOxTU)^v|7TDbkyUTcw}4gDi3d2 zou7;;!SLM{%hUeN)qpK_T+AhKY6OTA>SoJDZT+sn!bB`y&KM~rR*Tzg|1{{K&m3f^ zz_@%i=?EQdO!0p*eX+*zH|UI1_}^T!W7PkL`HL|lA9EM@a_(d8j`{p43z z0$ug(4Ux*=!qn;Tbd#ufmps+^x^KxTv1rI-eXvdOxY_dg{pn98`9XS_KQ+^aehN8~ z&t>>pzAfzH{ZH%O?ACup)mf+>f9-Cev+%`PFC<9z9bS2xc>t)e%FK;%dn~li03I4Y-+5g&ht-7-u-IrQ*q38G)EZ0cp90SuLSuri zmcx!l5BVZ~zhvjUWIx=%xfu?BepyGT6#o5eV)ZBE!qU^L%Z9$Wi>R0F&rIfWN^V&e z&0lm+4(iz7%VL{Ik)otyUn*&|D$Wp~0OEYGQM^(1PsEYke}XKA1R1+mHBciN*xPxYI2!$ z+Tn9u>klo3T&5B7w-#Lk4BGK-42yk!hUk=~;?H7_e@j086MTE>^H_PzV zIPv@r4Scrl=EsAkUv@izKbk5YBZK5xxiv4s7g{x;jneubUz<{YoKFZUWu5btSLWN7 zb|7TTBz>#MyL=S0({rrNyZVl@C zEXuNZ{IF^%#3@|faLFt-KaNN{;4)l$Eb1<7;J(%*VB(7e1b7*v#RK*I+1K7FyOfz3 zN2D0*I#}K~`-FyjHYxoyIIF_jG9_O5xnkX?Y@G34G_k~MEnAU$l`AQyX^<9HJKrCI)1Q@&=4bNpON%B{(h6%Q#g2y`rFvyagfjFvN6Fi8M(d}{=U55UiKInS?i5o zb=&caKX`$k`+>Eb+qXsKTLLZY{;UTR{d21{=;-i*Q2%FR{;?Y0oDN@ew!jt^=$b=C rJmPE~aj|24oadi5g>@X%#UDUE-qxLe$)~Rs<5BAtb>FeUxGmuX54Oik2z4Ia*E2Gq+N z=PXis0gsJ#h+GaqT2LBRw<6&Y;n|IaRpwL1WDDyqn99oC%s0<+DY+juiz$9Uc;+&_ zN_R=ox`_C*n@u%rRC$SPeoL$xI^A*bpIx?-^6O+VhKVzN?XdQx^PH}K^8W>MKmwgf*d zh?AI42-p&APkBdW`CtWGLNu%ps;T5Ii7dHp0^{JGyC8B=`B)9P6U`6izld;WuG(i0pDR|kh2lIg4PlKK+eO$r5 zMchMnp2|R{7HZQ?F*TUtRf)%qiYsy5*y>dUg`Ol)ra#4ey)ZFD%&Y7s&`PH2+j_XV+3rz6KSQ;G`2^4 zHjwTk-Xg(+$9zhU_{x1UNnB%L01kt1!F3U#za-Qca@T+mx)@;*#Tz>%NHD?*1q=zM zhR^Q7PYN=Jh#~=d2#-BMfgckj#HWHl2_Acj3qEF=C~(aM&{;{p6fuY?%8q6GCv43>S z(UFC$DAij@sgBY>nvBW;c1IMjs$ek_aE41*W5KlHc}H?0O-f~kCQ?I#n8ORS(0LA- zni^ihgry6tu@%|Fiq!E$8V%MQ6grR-?Z-i`J%zKHxF7j@i`;!Sff!7T9407{N8yYcI0Q= zY$N+Y>jj&eZ|sjULk`zOkJQY*Z+Y6%x@oor_n`IPrc}9-8oni4UXnFoc68!i>i9O# zzYIm(OC3u(8j*Bb*;p_>pV|{Q>$&>KkFWb$3SNBZO!(k5eW+0K%1{p^6r3BIs|-yo zF0FxbOrSl81S8yH48;(f2SOtP$(Vt#o34)RBkBpO%}7LBx*=hzjNgaHR`E*-Slg^+ zVFto>Gg5TDI4Cy8R)HYJR`HQ;Slb#!jBmZ%0;i2(?;uSH!084A3cKm^iAI!XXz)3Y zX{a`3ObYmhPSz{Ae3CV;gikjVwFgo1#MOa}*oH|nhNOyb=40Ie1T5SnlF^5UjSJMq z72sWhK!O5Q_zfbU3XARN!xMojr+s^z*qDYXGsb{; zQV}b;aZVBA*D!gX-sdufjO=gLSxNv7nz>fy%ht0{Ef0 z2V$iJszeqzj>h|ya+(ua2xm_mHBG}*y^cZ%iYj{Jx@MCvGGBS<}hOn(xo98g3opGP9qXHiHbkr=>MABJt*AU_0E2_HJH z)==85c$?lg?qo;iaD(N5iH8iJ@*vESI#A_}wrUPYV3-p~m2)_wCU9Y-1ew2}n!^Eb z@RKFNoRRrdnFCeHVS;l$f}`RnSd0*uGu+6c;rEXMM6Ae;D7)YC@(7pi=Z|W z6qKeP7qW7^dY{cj)mv#pP=p~t8v@x)D|`8*1#o+=@F3x$fXV+{#Az%Gc*!t+5ghQ-)+A{oy?mWIXR0JT9V zL^A4diZC~#NQ`9UJ1WB{U<&Z2d4WechU?rF`?C251-(u@1*#Jlu^FS@ary!K6+BrY z2GzHJeMd;_1-#C9iU#yxi4la*+akHhOAOj(u}c*uODN4X2iGZJ8vy{jlW;(dsvh>n ze&TV9KJlvf6gxF<9p4{76rKloq&w=haL`sJe&S(;us&Fd6@sFBgSuMb1L-Gf*C+0ZOb?@43lCLXSPM+Sc2$(M&=i8ceFQ#BDSGFlQA zhxLR5o!yOYM`!C}I|O1wY$mG47t7_BNO0LX#b)6K%NK!a76F$|n(3MwHWz6?I=Xrk z$TcFp%8jdwINR2C_39Pyw?l}`_I9_?&efBSUU z2m&Gl(f%0H_inqTx7B$rd;Nn)TBpuXbX31-;J{o8L0#GiV=2n(`z{^toNiqr5mZp1 zoQK+UptcB2cXx*_>G+(+vNd)D$}*J*9<;R7RX_Y|sHLU)*V9S|y5I)?>%shqTFo+@ z&9*&5*XlI-V$QnVIFx?t{nMS5@hG%K(bCdMpR1SlRsPA^ztDO7JJYW(22EKz-E@r& z^~O^ip(hz`+H*TUuH3UP7fRCUraL&=aW)N1dSiMU7CuN%ph6$!JzUZu`OaYbCi8zB ze$Q!eeY_~`z+%X?&6C)hn<^sNbLxjFmE&!f)%v$^(t;@jVkMeh18bz^Ve4AwqNy7#vG z=}l+3#nK;zE%=W)E=R;q2jy5RL;J^~`p%mddU|o;-j;6h*Z-hSB|FS!PG8!a^R4dE zR-#;}F_)qFyE5RAe%%S<%Q$a`eLn?v%+!)fP`|C7YTC}`UG@9LuJ378=MLt;*&k$a zueU|LblR&;UU-YYM+ocaPZNIF7am>Tcv!lYe0wO7<`QpG|1dkLgDEOE6?~Ni3b>Qtf1&rold~6nki-{g`oHxv+KS+Q?-OH&d(awN25jXE_H? z_HNu^^~*nZGG3!b@{(~+H_}ti7!){|g$J&#Sy#;&BKccoRPBFu;bACunfq0r!=uLk z<~~lh`)hpNv&*P&NqMteJv(Wg^fqt^6a-tYL{vd-Gp08@5wX*@1@-Kho172%T0(mi*FupzjVCzr`<-m`*($o_NH|z)`ob!y|u0Nu9g2f@%1A|pC+4N zY9v1ATRN@;((`JfRwbpn-Fv?KnpWI^uiW30Rq!S_QbXyozVM9naWsH>6v*X$;CU$N$PxHrf+5G$M<_* Q=-!0{`%OE(>RV*=KX8GjJ^%m! literal 0 HcmV?d00001 diff --git a/resources/sfx/select.ogg b/resources/sfx/select.ogg new file mode 100644 index 0000000000000000000000000000000000000000..4b0704d0d9f0e250b302ba838f2c611b1b91c536 GIT binary patch literal 4699 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 Uh3`_x$ov<4pPnslDVlEZU)AQE3jhEB literal 0 HcmV?d00001 diff --git a/resources/sfx/swap.ogg b/resources/sfx/swap.ogg new file mode 100644 index 0000000000000000000000000000000000000000..2ff2858a86af00936192999911f941d01b1b034b GIT binary patch literal 4363 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 F{sS5q<2wKV literal 0 HcmV?d00001 diff --git a/resources/sfx/swing_heavy.ogg b/resources/sfx/swing_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..56210592b98dd10619cb061e3c5c215f7fb3d11b GIT binary patch literal 5952 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 CpHIF3 literal 0 HcmV?d00001 diff --git a/resources/sfx/unequip.ogg b/resources/sfx/unequip.ogg new file mode 100644 index 0000000000000000000000000000000000000000..33110af8ac1edbb3b847d3b2af257b8ffe60c454 GIT binary patch literal 6261 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/uzi.ogg b/resources/sfx/uzi.ogg new file mode 100644 index 0000000000000000000000000000000000000000..839bb108665fd8cc89703a4b84eb3ee7fc9f2f24 GIT binary patch literal 4160 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/uzi_heavy.ogg b/resources/sfx/uzi_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..243404f7a36fa937644b6771485139415a1bc7ee GIT binary patch literal 6569 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 YW2nqV>?ut_!2WyWn{Ix%!3)X>F9jNwQ_WX3I1 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 z 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 YyrKa$Q+0CP(vU4LXk^a3il71g7eGw$zyJUM literal 0 HcmV?d00001 diff --git a/resources/sfx/zap.ogg b/resources/sfx/zap.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8a2ebf218ea211062405cdd746bab7f1acf2b668 GIT binary patch literal 5483 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 +#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 }}} + +