From e9d8cac926bb9d974bda232c9f6a13adf5d1dd4c Mon Sep 17 00:00:00 2001 From: shweet Date: Tue, 10 Nov 2026 15:27:12 -0500 Subject: [PATCH] Weapons built from traits, fashion, Hourglass, About tab, and Ithaca - Every attack is now assembled from traits on its definition instead of per-weapon code: an anchor and path (thrusts, out-and-back, arcs), shared flight physics (gravity, drag, acceleration, homing, and one wall response: end, burst, stick, crawl, bounce, stop, pass, return, latch), and flags like bouncy, drifting, tethered, orbiting, instant and arming. Melee are projectiles too. - Shots have a lifetime and a fade (when damage bottoms out). Range is speed times lifetime. Ricochets and the Vacuum wind lifetime back. - The Vacuum pulls anyone's shots, and its airblast flips them out along the aim, claiming enemies' (fading into your colour). - New trinket: Hourglass, longer projectile lifetime and range. Ropes and chains lengthen with it and the Accelerator. - Fashion: 23 items worn on the head, face or torso, chosen in Profile. - Flamethrower flames hit continuously, the whip coils and sweeps, bouncing shots roll over ramps, and Ricochet relights grenade fuses. - Profile tab, colour tooltips, option hints on every setting, an About tab with credits and links, and the Ithaca font. - Loadouts lock once readied, tomatoes clear with the match, and the round-over pause is 4 seconds. - CC0 license. Tools and CLAUDE.md are no longer tracked, and the web shell moves to web/. --- .gitignore | 3 + CLAUDE.md | 29 - CMakeLists.txt | 8 +- LICENSE | 121 ++ README.md | 6 +- resources/font/LICENSE.txt | 92 + resources/font/big.fnt | 200 +- resources/font/big.png | Bin 2324 -> 2030 bytes resources/font/huge.fnt | 200 +- resources/font/huge.png | Bin 3058 -> 2590 bytes resources/font/pixel_purl.ttf | Bin 11388 -> 0 bytes resources/font/small.fnt | 200 +- resources/font/small.png | Bin 1704 -> 1504 bytes resources/gfx/effects.anm2 | 10 +- resources/gfx/effects.png | Bin 10873 -> 11051 bytes resources/gfx/fashion.anm2 | 65 + resources/gfx/fashion.png | Bin 0 -> 11220 bytes resources/gfx/player.anm2 | 88 +- resources/gfx/trinkets.anm2 | 240 +-- resources/gfx/trinkets.png | Bin 6391 -> 6890 bytes resources/gfx/weapon_icons.anm2 | 192 +- resources/gfx/weapon_icons.png | Bin 4446 -> 5484 bytes resources/gfx/weapons.anm2 | 91 +- resources/gfx/weapons.png | Bin 11080 -> 4749 bytes resources/sfx/SOURCES.md | 20 +- resources/sfx/beep.ogg | Bin 0 -> 4356 bytes resources/sfx/bolter.ogg | Bin 0 -> 6066 bytes resources/sfx/bolter_heavy.ogg | Bin 0 -> 7162 bytes resources/sfx/donk.ogg | Bin 0 -> 7818 bytes resources/sfx/grenade.ogg | Bin 0 -> 6687 bytes resources/sfx/grenade_bounce.ogg | Bin 0 -> 4958 bytes resources/sfx/hit_air.ogg | Bin 0 -> 5038 bytes resources/sfx/hit_air_heavy.ogg | Bin 0 -> 6200 bytes resources/sfx/hit_electric.ogg | Bin 0 -> 6378 bytes .../sfx/{zap.ogg => hit_electric_heavy.ogg} | Bin 5483 -> 5483 bytes resources/sfx/vacuum.ogg | Bin 0 -> 5193 bytes resources/sfx/vacuum_heavy.ogg | Bin 0 -> 7015 bytes resources/sfx/vacuum_start.ogg | Bin 0 -> 6699 bytes resources/sfx/vacuum_stop.ogg | Bin 0 -> 6687 bytes server/server.cpp | 118 +- src/anm2.cpp | 116 +- src/anm2.h | 16 +- src/audio.cpp | 62 +- src/audio.h | 29 +- src/balance.cpp | 5 +- src/bot.cpp | 120 +- src/bot.h | 12 +- src/chat.cpp | 37 +- src/chat.h | 5 +- src/common.h | 8 + src/dmath.h | 9 +- src/effects.cpp | 54 +- src/effects.h | 19 +- src/fighter.cpp | 180 +- src/fighter.h | 99 +- src/game.h | 23 +- src/input.cpp | 34 +- src/input.h | 19 +- src/main.cpp | 19 +- src/match.cpp | 167 +- src/match.h | 47 +- src/menu.cpp | 475 +++-- src/menu.h | 34 +- src/net.cpp | 38 +- src/net.h | 4 +- src/protocol.h | 19 +- src/renderer.cpp | 27 + src/renderer.h | 2 + src/scene.cpp | 352 +++- src/scene.h | 10 +- src/session.cpp | 89 +- src/session.h | 28 +- src/settings.cpp | 460 ++++- src/settings.h | 33 +- src/sim.cpp | 5 +- src/sim.h | 4 +- src/sprites.cpp | 49 + src/sprites.h | 5 + src/text.h | 4 +- src/trinket.h | 292 ++- src/ui.cpp | 640 +++--- src/ui.h | 44 +- src/weapon.cpp | 1595 +++++++++------ src/weapon.h | 1776 ++++++++++++----- src/world.cpp | 1 + src/world.h | 3 + tools/assets_generate.py | 777 -------- tools/balance_runs.py | 67 - tools/determinism_check.sh | 84 - tools/fonts_export.py | 189 -- tools/sfx_build.py | 367 ---- tools/sfx_library_demo.py | 260 --- tools/sfx_loudness.json | 91 - 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}/node_pre.js | 0 {tools => web}/web_pre.js | 0 {tools => web}/web_shell.html | 0 101 files changed, 5787 insertions(+), 7148 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 resources/font/LICENSE.txt delete mode 100644 resources/font/pixel_purl.ttf create mode 100644 resources/gfx/fashion.anm2 create mode 100644 resources/gfx/fashion.png create mode 100644 resources/sfx/beep.ogg create mode 100644 resources/sfx/bolter.ogg create mode 100644 resources/sfx/bolter_heavy.ogg create mode 100644 resources/sfx/donk.ogg create mode 100644 resources/sfx/grenade.ogg create mode 100644 resources/sfx/grenade_bounce.ogg create mode 100644 resources/sfx/hit_air.ogg create mode 100644 resources/sfx/hit_air_heavy.ogg create mode 100644 resources/sfx/hit_electric.ogg rename resources/sfx/{zap.ogg => hit_electric_heavy.ogg} (95%) create mode 100644 resources/sfx/vacuum.ogg create mode 100644 resources/sfx/vacuum_heavy.ogg create mode 100644 resources/sfx/vacuum_start.ogg create mode 100644 resources/sfx/vacuum_stop.ogg delete mode 100644 tools/assets_generate.py delete mode 100644 tools/balance_runs.py delete mode 100755 tools/determinism_check.sh delete mode 100644 tools/fonts_export.py delete mode 100644 tools/sfx_build.py delete mode 100644 tools/sfx_library_demo.py delete mode 100644 tools/sfx_loudness.json delete mode 100644 tools/sfx_review.py delete mode 100644 tools/sfx_review_notes.json delete mode 100644 tools/sfx_review_round1.json delete mode 100644 tools/sfx_review_round2.json delete mode 100644 tools/sfx_suites.py rename {tools => web}/node_pre.js (100%) rename {tools => web}/web_pre.js (100%) rename {tools => web}/web_shell.html (100%) diff --git a/.gitignore b/.gitignore index a3c19e7..86a125a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ build/ build-check/ build-web/ __pycache__/ +tools/ +CLAUDE.md +*-autosave.kra diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 25d40a3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,29 +0,0 @@ -- 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 index 01686e3..86afca7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ 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. +# library's transcendentals are replaced by src/dmath.h. if(MSVC) add_compile_options(/fp:precise /fp:contract-) else() @@ -77,12 +77,12 @@ if(EMSCRIPTEN) "--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") + "--shell-file=${CMAKE_SOURCE_DIR}/web/web_shell.html" + "--pre-js=${CMAKE_SOURCE_DIR}/web/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" + "--pre-js=${CMAKE_SOURCE_DIR}/web/node_pre.js" "--embed-file=${CMAKE_SOURCE_DIR}/resources/gfx@/resources/gfx" "--embed-file=${CMAKE_SOURCE_DIR}/resources/font@/resources/font") endif() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index dcf7cb6..eca357b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Open the UDP range in the firewall (e.g. `ufw allow 47810:47899/udp`). - The server runs every lobby: a sandbox everyone in the lobby shares, plus the games queued in it. Each is its own simulation. All of them run on one thread, at 60 ticks a second. - Clients send their inputs ahead of time. Each tick the server takes what has arrived (repeating a player's last inputs if they're late), steps the simulation, and sends everyone the inputs it used. Every 60 ticks it also sends a checksum. - Clients run the same simulation about 3 ticks ahead of the server, guessing other players' inputs. When the server's inputs differ from the guess, the client rolls back and replays. -- The simulation is deterministic on every platform (strict floating point, own maths functions), so desktop and web play together. `tools/determinism_check.sh` checks this. +- The simulation is deterministic on every platform (strict floating point, own maths functions), so desktop and web play together. - Clients connect over a WebSocket first (lobbies, chat, WebRTC signaling). Then they open a WebRTC connection to the server with two data channels: a reliable one, and an unreliable one for inputs and frames. If WebRTC can't connect, everything stays on the WebSocket. - STUN: `stun.l.google.com:19302`. - The server address is set in Settings. Default: `wss://shweetz.net/smack-a-brother/ws`. @@ -90,3 +90,7 @@ Open the UDP range in the firewall (e.g. `ufw allow 47810:47899/udp`). - `SAB_NET_LAG`: add this many milliseconds of lag - `SAB_NET_LOSS`: drop this percent of input packets - `SAB_NET_STATS`: log how the client is keeping up with the server + +## License + +The game's code and art are released into the public domain under [CC0 1.0](LICENSE). Not covered by that: the Ithaca font (SIL Open Font License, see `resources/font/LICENSE.txt`), the sound effects cut from the Sound Ideas libraries (see `resources/sfx/SOURCES.md`), and the libraries in `external/`, each under its own license. diff --git a/resources/font/LICENSE.txt b/resources/font/LICENSE.txt new file mode 100644 index 0000000..0ed1d15 --- /dev/null +++ b/resources/font/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright © 2025 GGBotNet (https://ggbot.net), with Reserved Font Name "Ithaca". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/font/big.fnt b/resources/font/big.fnt index b6218c1..88aa10d 100644 --- a/resources/font/big.fnt +++ b/resources/font/big.fnt @@ -4,105 +4,105 @@ "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}, - "°": {"x": 345, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, - "←": {"x": 0, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, - "↑": {"x": 23, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, - "→": {"x": 46, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, - "↓": {"x": 69, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14} + " ": {"x": 0, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "!": {"x": 17, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "\"": {"x": 34, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "#": {"x": 51, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 16}, + "$": {"x": 68, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "%": {"x": 85, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "&": {"x": 102, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "'": {"x": 119, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "(": {"x": 136, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + ")": {"x": 153, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "*": {"x": 170, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "+": {"x": 187, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + ",": {"x": 204, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "-": {"x": 221, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + ".": {"x": 238, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "/": {"x": 255, "y": 0, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "0": {"x": 0, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "1": {"x": 17, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "2": {"x": 34, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "3": {"x": 51, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "4": {"x": 68, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "5": {"x": 85, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "6": {"x": 102, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "7": {"x": 119, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "8": {"x": 136, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "9": {"x": 153, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + ":": {"x": 170, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + ";": {"x": 187, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "<": {"x": 204, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "=": {"x": 221, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + ">": {"x": 238, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "?": {"x": 255, "y": 27, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "@": {"x": 0, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "A": {"x": 17, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "B": {"x": 34, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "C": {"x": 51, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "D": {"x": 68, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "E": {"x": 85, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "F": {"x": 102, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "G": {"x": 119, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "H": {"x": 136, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "I": {"x": 153, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "J": {"x": 170, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "K": {"x": 187, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "L": {"x": 204, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "M": {"x": 221, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 18}, + "N": {"x": 238, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "O": {"x": 255, "y": 54, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "P": {"x": 0, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "Q": {"x": 17, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "R": {"x": 34, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "S": {"x": 51, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "T": {"x": 68, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "U": {"x": 85, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "V": {"x": 102, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "W": {"x": 119, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 18}, + "X": {"x": 136, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "Y": {"x": 153, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "Z": {"x": 170, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "[": {"x": 187, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "\\": {"x": 204, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "]": {"x": 221, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "^": {"x": 238, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "_": {"x": 255, "y": 81, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "`": {"x": 0, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "a": {"x": 17, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "b": {"x": 34, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "c": {"x": 51, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "d": {"x": 68, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "e": {"x": 85, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "f": {"x": 102, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "g": {"x": 119, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "h": {"x": 136, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "i": {"x": 153, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "j": {"x": 170, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 8}, + "k": {"x": 187, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "l": {"x": 204, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "m": {"x": 221, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 18}, + "n": {"x": 238, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "o": {"x": 255, "y": 108, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "p": {"x": 0, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "q": {"x": 17, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "r": {"x": 34, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "s": {"x": 51, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "t": {"x": 68, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "u": {"x": 85, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "v": {"x": 102, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "w": {"x": 119, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 18}, + "x": {"x": 136, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "y": {"x": 153, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "z": {"x": 170, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "{": {"x": 187, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "|": {"x": 204, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 6}, + "}": {"x": 221, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "~": {"x": 238, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 10}, + "°": {"x": 255, "y": 135, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 12}, + "←": {"x": 0, "y": 162, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "↑": {"x": 17, "y": 162, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "→": {"x": 34, "y": 162, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14}, + "↓": {"x": 51, "y": 162, "w": 16, "h": 26, "dx": 0, "dy": 0, "advance": 14} } } diff --git a/resources/font/big.png b/resources/font/big.png index a64738ca3b780834ca7a998b91bd3875252c2f67..91d8d77698e0bd134bf689cea37a06dcc35b960d 100644 GIT binary patch literal 2030 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": {"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}, - "°": {"x": 510, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, - "←": {"x": 0, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, - "↑": {"x": 34, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, - "→": {"x": 68, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, - "↓": {"x": 102, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21} + " ": {"x": 0, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "!": {"x": 25, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "\"": {"x": 50, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "#": {"x": 75, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 24}, + "$": {"x": 100, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "%": {"x": 125, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "&": {"x": 150, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "'": {"x": 175, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "(": {"x": 200, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + ")": {"x": 225, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "*": {"x": 250, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "+": {"x": 275, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + ",": {"x": 300, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "-": {"x": 325, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + ".": {"x": 350, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "/": {"x": 375, "y": 0, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "0": {"x": 0, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "1": {"x": 25, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "2": {"x": 50, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "3": {"x": 75, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "4": {"x": 100, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "5": {"x": 125, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "6": {"x": 150, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "7": {"x": 175, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "8": {"x": 200, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "9": {"x": 225, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + ":": {"x": 250, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + ";": {"x": 275, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "<": {"x": 300, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "=": {"x": 325, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + ">": {"x": 350, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "?": {"x": 375, "y": 40, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "@": {"x": 0, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "A": {"x": 25, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "B": {"x": 50, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "C": {"x": 75, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "D": {"x": 100, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "E": {"x": 125, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "F": {"x": 150, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "G": {"x": 175, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "H": {"x": 200, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "I": {"x": 225, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "J": {"x": 250, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "K": {"x": 275, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "L": {"x": 300, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "M": {"x": 325, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 27}, + "N": {"x": 350, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "O": {"x": 375, "y": 80, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "P": {"x": 0, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "Q": {"x": 25, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "R": {"x": 50, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "S": {"x": 75, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "T": {"x": 100, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "U": {"x": 125, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "V": {"x": 150, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "W": {"x": 175, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 27}, + "X": {"x": 200, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "Y": {"x": 225, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "Z": {"x": 250, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "[": {"x": 275, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "\\": {"x": 300, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "]": {"x": 325, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "^": {"x": 350, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "_": {"x": 375, "y": 120, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "`": {"x": 0, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "a": {"x": 25, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "b": {"x": 50, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "c": {"x": 75, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "d": {"x": 100, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "e": {"x": 125, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "f": {"x": 150, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "g": {"x": 175, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "h": {"x": 200, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "i": {"x": 225, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "j": {"x": 250, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 12}, + "k": {"x": 275, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "l": {"x": 300, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "m": {"x": 325, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 27}, + "n": {"x": 350, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "o": {"x": 375, "y": 160, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "p": {"x": 0, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "q": {"x": 25, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "r": {"x": 50, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "s": {"x": 75, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "t": {"x": 100, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "u": {"x": 125, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "v": {"x": 150, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "w": {"x": 175, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 27}, + "x": {"x": 200, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "y": {"x": 225, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "z": {"x": 250, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "{": {"x": 275, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "|": {"x": 300, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 9}, + "}": {"x": 325, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "~": {"x": 350, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 15}, + "°": {"x": 375, "y": 200, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 18}, + "←": {"x": 0, "y": 240, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "↑": {"x": 25, "y": 240, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "→": {"x": 50, "y": 240, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21}, + "↓": {"x": 75, "y": 240, "w": 24, "h": 39, "dx": 0, "dy": 0, "advance": 21} } } diff --git a/resources/font/huge.png b/resources/font/huge.png index c8a79f4bf864c6885fe2c7c126bbb80406bbae0c..65577bd518121cf06bc0db2fd85060ca2e31d052 100644 GIT binary patch literal 2590 zcmZuz2{>En8a|Y^=<436rKr-micnQw^R zcTy5b);9GUsQgw*9a7m9f~W)$9cE$P}-|HQ;)V_NU#lnQ<$^E@TR&svC! z9jSDX35CN8BN^UE*dc91^<D*Hwh`xkIw#<*gV09RD8-}n)~Z{rm3w*DX;)!#8V7)*aZziLSu~TITYM=gnFlb+obs5H+ zAi~Lrc9qq6M1m^Jf58;O%L1vYI%;j#F<`*d>^O2+BM;ru2=_Gg8{h7uJ>!PD$n z*ypvJxs!GklP&J%FH$1t-bldS@+%nuG88`^RR}X8sB59BYF+9WYa{H@Tt{Fq|FD+} zD^|N&*-eD$NOd{Ryo1}Dd{rBXMGPE7*VQVqdpQ9{9*l9fzsccbNP3}(T~EqC7V6-u zUmP3fzNc{-(TU9kq*O3vYC=xH^`{?TPplY9ZbgWQMzdKnQ>eZc>XiEwx8J*8kw}n} z16;leOcQ!zp43EMwE=0ewR(2^8cY)#_5 z`D%_g5~^?2>`$ptoPVPq?5b<;d%omIo6$+s8SVD|B5osA@ahL*{{Z=AuO;gmk^b?P(dC-R(#?1 zmOD`X!e{wT?T#C|xZ&R~5h7j7GY)L^n5wuP45#S{m-c&Y;b->0{*WW4&G_^l6R6Oh zmACo+3d&P}K$skQIz60L$yw6@QF)<`MW>xR%nq5k>N+1XH8V_0ih%MuLf~S&{7LI6 zCy?jcP@R(oS%l}grQJ=Bo)JM1`{*}!p|Nk>7~Kw>XQFnt4~=Zp@ti%Amc6tVvgfVy zVmFIfrx?NKcDe^)h|Q%Q?I2;n9GTF7fB~ZtNTi6ueLxSx*3dj9`Ty|-UDzVBV{kO~ee@{~0<4BD zAwM^_pV;x!hcn4_e#Vu@S=?^A__rE%%5<pK&`FMT;)RezSHqu;BfU*QyZO|3!|`6#|r{SQY$%v z=KEW${;i$-@9U+i7X?Z_>BEO(+NS!PNRY)iCRt_I^F6uNyTAP~EUkUkgHs=pP=2np ztmTt>=AX)?Vz2-J literal 3058 zcmaJ@eOOXy7QdNVR+~*enl)lJGiG6yMELjtnl1UArgFth)U?qYKW4H})I`(WW;6$< zaVhb06BB`e{LC-Vc2Y{sQbSBKCS6lOv_?rq%Y~h~w)4!h?;kw(Ue0^YdC%|nJLkFxTLO}jP$0Pq)gH>@83Xqf@PI;)Mk>Mw{@NhknpZgR&u<4D7bk-qqfMkAdkUHev7 zyNSEGN6J%%M-np>S^U9)+~)0DuY57+XdH4#y%AbfGck$l(&xx+(p95nr6K2Ty@G}U zqhf!fM38Y`uM8+b6Mgaa(rNDOQ>WUbEx=Ho-bP^C0;VNH8r^{z?Go%e@R{@7284=M ziz<00wtUREFK~R=W8)@ov7S@iRA{Z5uUiLil6z z$7hoiyT#R;t}xu|e9g^(qs1Ec44pXu&#`6JY%=4FW-(`moyMNOc-v!$5J{R0^I-D) z3;;O4S8W}FtY~Z~ zAPtG{!XHRWg3$j=JSZX@f$7gxqf_tB-J-I)xgXTPmwc zAn<;Ju@9Rf{q!M2Cwb2{;K`+HV|~;^+vg7& ze@H#XW&hN^N?~;+O;T!S3d)Q*%C5qcPBV-tGOv4*gkwf4$FzK&s3<0FJ1}KSdQj}N z`xZ!g!S(sKaEdF{p373|Ng7!c3sG~Idei!Vn>y=(1HWjBvs_AOpDk`;uX=!^LvUgp z&Mw685jv659SjbmL0Oew3>AzJ<$0DnLcXc7_vW8-9Q`|}c1U?uLMsbf2+Gbiu_=Se zC)3am*a@&A=IDYsKkm~VIGnjld>qg4CD@dTM(9qxnPeU!xGIeT!_h8uMGG!^4~|J! zp)OX^#7f#%pHOTA_{A@9V(y?=nYBvWCFL8@GlTuz3ewo`N#7$Xp*-(6AYFS4+m$VB4o)u04l2 zkB53)ymZY^yp^z6bE_{-3JO7G2`Bnhb>K}3_5@vNE;z)A%O$_(+DCdkl{E(Ah1BJf zu#iQHwsMy{*-Cl~TDzHvg5FH7lPq?a# zok7m*F;z<8d+*+rR7*(DFO9sE4y*VhyRJPliSkVkp^9|SH!e*2H!Er^A%uwaVceAO zt{R)Znj0BY_9re4knyKJtF?y=P^Ybv&|Kb}cvRASS@V0SSYd(H%efeK=LUY)(g-cZy&8?O;yWa7$7vUJws4vDX#xM7D#d9;E9q4z3y+2{ zO7Hy+HkM>4jXQ%@ed7^Z6xH`Oa}}B<&-<^vhFckDSf0~w*ja>z=#YIKu&I`JH5rYa zA7R}t%gu*;UZ-3KGHf(zwp6S656fCA%ewLIyv zqbXMR!*G%cSM6NR2q&i(rK5)zdGPZNxPDcU7Vm~SP(iyU+H(B6tjR5;a(EmMPCN=i z-DY`a=}FJN>d~xN^bo`Qbo?vQe5^wnH`nwAZNejEOJcM4?O-`|dH4eR-elBv;#ZxO z<6ZWjqDNyIF7#YF8Kk-#NAC-x+RylXjb;fe!GN0N_LsHO7Z0Qin?*V1SB-TcY(1Xt zfuQ+KJK=}3W0w^c8@xsiBYM`r{MpL5_ZiB2Bs%ov0sSBT zcgH+73wcYrjIdWvqQ-8TeL(I%*d{wlJsl!Lk*fkoY2FpO`eK88hJLVxqfW_~xVa_2xY2Udye|3)4TE+8#8Qv0hwuJrOX3gT4A7q!=xe&`e zNmpo!?{heLIjBb_S%YLLk$)rO!-eDd!n78N5)iBh(4i_W^kwe+aVYAAT}c&_IPqVpDj*l;14X`t@!qk z?JmNZ0@FNg$U6ESx_xr}t=puGrN{e$CCLx!zY!G=K0l~fj- z?dYQJH-f$m8)t^KhN45G<#zf3PI%~)YNazx_w#1lebcEes(~QXqt+q;D3qzoG$Hh* ziV)Uof_DrTSa2Fhew?FLEg?>kGf5o?Uf_T?dU1ZgrKlff*3vs;J$*~y@D4+TnruOTZ_zxVHn|YiO!nGKRE&b-1+C~x)JkuJMGs} Pw8p^wxHp!2EG+XsQKJ+% diff --git a/resources/font/pixel_purl.ttf b/resources/font/pixel_purl.ttf deleted file mode 100644 index 167680b2ab05ae162caceaabb509e4d0bf9f11e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11388 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 X9OvzRFV$cC2^&AWFm(@0eslYKuphHF diff --git a/resources/font/small.fnt b/resources/font/small.fnt index 3b2b6e2..7ed8bff 100644 --- a/resources/font/small.fnt +++ b/resources/font/small.fnt @@ -4,105 +4,105 @@ "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}, - "°": {"x": 180, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, - "←": {"x": 0, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, - "↑": {"x": 12, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, - "→": {"x": 24, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, - "↓": {"x": 36, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7} + " ": {"x": 0, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "!": {"x": 9, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "\"": {"x": 18, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "#": {"x": 27, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 8}, + "$": {"x": 36, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "%": {"x": 45, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "&": {"x": 54, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "'": {"x": 63, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "(": {"x": 72, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + ")": {"x": 81, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "*": {"x": 90, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "+": {"x": 99, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + ",": {"x": 108, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "-": {"x": 117, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + ".": {"x": 126, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "/": {"x": 135, "y": 0, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "0": {"x": 0, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "1": {"x": 9, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "2": {"x": 18, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "3": {"x": 27, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "4": {"x": 36, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "5": {"x": 45, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "6": {"x": 54, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "7": {"x": 63, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "8": {"x": 72, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "9": {"x": 81, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + ":": {"x": 90, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + ";": {"x": 99, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "<": {"x": 108, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "=": {"x": 117, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + ">": {"x": 126, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "?": {"x": 135, "y": 14, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "@": {"x": 0, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "A": {"x": 9, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "B": {"x": 18, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "C": {"x": 27, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "D": {"x": 36, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "E": {"x": 45, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "F": {"x": 54, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "G": {"x": 63, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "H": {"x": 72, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "I": {"x": 81, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "J": {"x": 90, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "K": {"x": 99, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "L": {"x": 108, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "M": {"x": 117, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 9}, + "N": {"x": 126, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "O": {"x": 135, "y": 28, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "P": {"x": 0, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "Q": {"x": 9, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "R": {"x": 18, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "S": {"x": 27, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "T": {"x": 36, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "U": {"x": 45, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "V": {"x": 54, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "W": {"x": 63, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 9}, + "X": {"x": 72, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "Y": {"x": 81, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "Z": {"x": 90, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "[": {"x": 99, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "\\": {"x": 108, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "]": {"x": 117, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "^": {"x": 126, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "_": {"x": 135, "y": 42, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "`": {"x": 0, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "a": {"x": 9, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "b": {"x": 18, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "c": {"x": 27, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "d": {"x": 36, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "e": {"x": 45, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "f": {"x": 54, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "g": {"x": 63, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "h": {"x": 72, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "i": {"x": 81, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "j": {"x": 90, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 4}, + "k": {"x": 99, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "l": {"x": 108, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "m": {"x": 117, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 9}, + "n": {"x": 126, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "o": {"x": 135, "y": 56, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "p": {"x": 0, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "q": {"x": 9, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "r": {"x": 18, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "s": {"x": 27, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "t": {"x": 36, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "u": {"x": 45, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "v": {"x": 54, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "w": {"x": 63, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 9}, + "x": {"x": 72, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "y": {"x": 81, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "z": {"x": 90, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "{": {"x": 99, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "|": {"x": 108, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 3}, + "}": {"x": 117, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "~": {"x": 126, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 5}, + "°": {"x": 135, "y": 70, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 6}, + "←": {"x": 0, "y": 84, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "↑": {"x": 9, "y": 84, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "→": {"x": 18, "y": 84, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7}, + "↓": {"x": 27, "y": 84, "w": 8, "h": 13, "dx": 0, "dy": 0, "advance": 7} } } diff --git a/resources/font/small.png b/resources/font/small.png index 48894685ccdb25773c6072670dc1f3850c556680..379d9d03583c8c922f4ec48dda01d39e9396694f 100644 GIT binary patch literal 1504 zcmV<61t0o}P) z(U#*N2!@lMdH+|=UhHHK%_{r|8e{(Nrk$1`8brityKRK5$Z;IU-8M)xduFqBBp*cR z_{EZukBF|CUP~9E0|C8uq|B_;#tL$vk(@^E`@YM+XR)UQVkFk4hX9B2kx2vMQ)5JCtcDg3v?uqwZ3B^9Y4Tf3H8o25L!Qxo#)$?n&au=@2u z3T{Z^hU$?;$>`2z7oc`Uuh+(BwW1}`FSrto$XIftXXZ@x8KiLMDegrJfy)*Ww+%lF z(gs1kj@-Cyo5Rr%EPIa&B^e}IB0(YDfjqO!$~P>~LGbF;u8&m4kqF(I7nbx``p{HD zYcth>kVF#Afi#eT&H~8;M&>@An{F*ro=H)YNpm2?d0O@LyfInJm%P5Wb6ZY?L?E=^ z)?`ipe;}^XOkNLxtXH9bgMNI5{tU0C6}mJTk3AYvH)2am8?9K?XzSW-OE``1!5xFCF`GRZ zAdVf~`F%MMw~@q4`ccNns;sp>_mb(&$T5`pPz$al3~4BuPk4ytKvD*2tJO{~BgZgG z(Sd4%wOWoq{t~&bUrX3X2YK58O*S55tQ|qXnYt`7QB0mMBB0d_V{9wV;_?IFlbEF8ZW%VWy>j*(J;y zInLLzBC%yxmY%yO;qae(y1T<97uPqGMQ7(|J?HCI&E$MUiSZ;%IqP8+zq1%2}36$x6>mx%Fr3rfnJZuSuz-v$D#-Qdnt@jIxKs`FYVmN;zO-tA;%xF^vBXhSXO-tA;?1H;($z+z3c7)sh zog-^II@`APZ8V+6YiQ95IYnpim~5hCDlDZ1=fZN^4q zkms#!NmkgAHGMmWM)Y(SIYoaWvmU!d(}gZe7&1^Z4(157o0VBBvhpCraga*%&>Tn_ z^pqqyvz%`&-`UA;t(|L|nv4}WhbPg}m?t<9vg~^FNL_c5wE`^r*?1&!l$;`S8PV)o z1?c;MvLXh$99Zq4_MCE7h&~??`G`HI%#)UI6K`jh>U;+>#z)hE=pT8Hmi}1JD6Tgv zx=bMvv-NsH2qA56 literal 1704 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 lq{{cu1R>c+EX>tGn002ovPDHLkV1jsqJ-Gk? delta 2123 zcmY*Zc{J1u1D)U4rD8CK%3$nc8M4)vW(;Czk^PI3UC0)mj5ohA5y_qjiBZ$yYlgCv zQnpBpXDJ@Cmy8sKv5c`a@4fSV=R5D+zwW)~+<)#l_cGl1uDJ@5)8e8K2&9`8kyovH zSPhgW*jTwHj4qb$jhRuAN?lw~W}1f)>&o=J8XmUQkb!Hs<;LQZvX84Fmg*~7NU=Y& z{{*>fd8d7Xc-MqZnx)Yy8vCw28f5J7c)Xz75bd*X-i?ioVL|8+&z^$_&Jl`x^Tk(nN0z}WS{4Ky?0Q@zKR<3gP*KL6iSBJ=zve zx<$9DvLpKjT0Ah0VZ)qiq?*DBBAcJi?rtcYwW?ULTW_ITAYJjbp3xk=`y-jJabwhv2k?TMhc zyNQ!Q3ViTWZYypgper8f;a3}kJQSnQpjd0yZT>9q8K)=g)7InrK00VjNs^#T-EkOe zw!uTneHf7HRTnq_-M>#E!H%*))ej(~6N$nSpf|KlJeq9en(}vQ>!j0oo^S!FQP#u9 z6~<5H-h^LkiX>$}*%7&vcV;NzZ|(h`w!rg+{l=cUk0uHMlGJ7;#ZFi}aE06PZRE|k zi-p12PHJ6+NP6jsX67gEC%s5}QZ=w<; zB_kt+uir@>1e7Kvl$cFFX?{L)|1Vr|4>Gza8}gFea~?1#zlXM0t;>eMuZZZFPhXYt zOLp^5mIF+r_9W-ZtB8^@XV$|DxtEmSo^=h$QF^W z$GSD4J5Nz3Zwh;oli@1$bG!hQ5pD`G&ATVezJ%zNH8MbP<%L#M*ApjOPOm~v>|z`A!s~z4lD=n#y&fk$;XaJ*JuGI8>Ahlwr{jk_YR=3&*uj$ z&2h{1#T?phrb_tu;Mxonvpc<1M4S1njhv+?Ik$(?6AE&;XOC&pZ1TKhTHHvX*!x3? z#%4W;A6f^T_?ya5*IaV!N()W$xOK`0oSb-nmuZM9Gz<`-1H6V`v&rCVxHRzv1eI#b z2Ato2cF;`kl)m3Nn3mW;Ll{%Fje`^iHPc53{Hh;K&M}fpkv-evs0dFopUQyD_uQ?xtV%^5PTbCQ#d@5lP8t9PlXEtJ@$=MkLmd3mc5 zIQz9J&kvO@L$jJh=ApfM^#}E;J4#V)J~5@y{5YLePGo*aDZ*G2rPawL21{5G&yfS2 z+c$$}7T;eWPWfd85qWNypy8=2tip5qBAjxn7S$(z5W$*%vAg}@D(kNT*p+5h8}~vs zp3ZN+j0=kLIbHdw*}5NQUT(RC4LenB)Ld13`SK(_OTN29MyX`D0pjt(=Y3@lsHV$d zEfpQGkaYhZ%5)7wV;CY8N}>&VW^ti>@9}>1?8at($ryS!s|b}A;n??cy-dvhU~a}1<>N3*I;7F58Qv0=tV+r3nmfZexY<<^+o+09i_ zkalj7Vnm?=>X|%xodPT_mHPDgdFAJg{Y^->Xu;R2*dvKSvbUH#eFe2kalq{Xzrp9n*NH@96*qrpv+aH+ z@Pi4!8^wxgJ>REy6FNDl<^;`tO}uz%1=T)>vXjiUuDzIl*sn9v&{V)Qwf%4){=y+E pk{3U?8*hK85dYJY|G#JL*%TW77Ca@nYsLM`A=sX_sls2R`~ee;(t`j1 diff --git a/resources/gfx/fashion.anm2 b/resources/gfx/fashion.anm2 new file mode 100644 index 0000000..236adb3 --- /dev/null +++ b/resources/gfx/fashion.anm2 @@ -0,0 +1,65 @@ +{ + "anm2ed": 1, + "info": {"fps": 60}, + "spritesheets": [ + { + "path": "fashion.png", + "regions": [ + {"name": "Backwards Cap", "size": [32, 32], "pivot": [17, 18]}, + {"name": "Headphones", "crop": [32, 0], "size": [32, 32], "pivot": [16, 17]}, + {"name": "Hard Hat", "crop": [64, 0], "size": [32, 32], "pivot": [16, 18]}, + {"name": "Bandana", "crop": [96, 0], "size": [32, 32], "pivot": [16, 14]}, + {"name": "Bow", "crop": [128, 0], "size": [32, 32], "pivot": [14, 17]}, + {"name": "Moustache", "crop": [160, 0], "size": [32, 32], "pivot": [16, 13]}, + {"name": "Eyepatch", "crop": [0, 32], "size": [32, 32], "pivot": [15, 16]}, + {"name": "Monocle", "crop": [32, 32], "size": [32, 32], "pivot": [13, 13]}, + {"name": "Top Hat", "crop": [64, 32], "size": [32, 32], "pivot": [16, 20]}, + {"name": "Ten Gallon", "crop": [96, 32], "size": [32, 32], "pivot": [16, 17]}, + {"name": "Fez", "crop": [128, 32], "size": [32, 32], "pivot": [10, 19]}, + {"name": "Bling", "crop": [160, 32], "size": [32, 32], "pivot": [16, 14]}, + {"name": "Propeller Hat", "crop": [0, 64], "size": [32, 32], "pivot": [16, 18]}, + {"name": "Headdress", "crop": [32, 64], "size": [32, 32], "pivot": [16, 16]}, + {"name": "Bycocket", "crop": [64, 64], "size": [32, 32], "pivot": [16, 20]}, + {"name": "Crown", "crop": [96, 64], "size": [32, 32], "pivot": [15, 20]}, + {"name": "Scoop", "crop": [128, 64], "size": [32, 32], "pivot": [16, 20]}, + {"name": "Petals", "crop": [160, 64], "size": [32, 32], "pivot": [16, 15]}, + {"name": "Witch Hat", "crop": [0, 96], "size": [32, 32], "pivot": [16, 20]}, + {"name": "Knight Helmet", "crop": [32, 96], "size": [32, 32], "pivot": [16, 17]}, + {"name": "Antennae", "crop": [64, 96], "size": [32, 32], "pivot": [16, 20]}, + {"name": "Devil Horns", "crop": [96, 96], "size": [32, 32], "pivot": [16, 28]}, + {"name": "Demon Horns", "crop": [128, 96], "size": [32, 32], "pivot": [16, 16]} + ] + } + ], + "layers": [ + {"name": "Head"}, + {"name": "Face"}, + {"name": "Torso"} + ], + "defaultAnimation": "Backwards Cap", + "animations": { + "Backwards Cap": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, + "Headphones": {"layers": [{"layer": 1, "frames": [{"region": 1}]}]}, + "Hard Hat": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, + "Bandana": {"layers": [{"layer": 1, "frames": [{"region": 3}]}]}, + "Bow": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, + "Moustache": {"layers": [{"layer": 1, "frames": [{"region": 5}]}]}, + "Eyepatch": {"layers": [{"layer": 1, "frames": [{"region": 6}]}]}, + "Monocle": {"layers": [{"layer": 1, "frames": [{"region": 7}]}]}, + "Top Hat": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Ten Gallon": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Fez": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Bling": {"layers": [{"layer": 2, "frames": [{"region": 11}]}]}, + "Propeller Hat": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Headdress": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Bycocket": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Crown": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, + "Scoop": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, + "Petals": {"layers": [{"layer": 1, "frames": [{"region": 17}]}]}, + "Witch Hat": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Knight Helmet": {"layers": [{"layer": 1, "frames": [{"region": 19}]}]}, + "Antennae": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Devil Horns": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, + "Demon Horns": {"layers": [{"layer": 1, "frames": [{"region": 22}]}]} + } +} diff --git a/resources/gfx/fashion.png b/resources/gfx/fashion.png new file mode 100644 index 0000000000000000000000000000000000000000..eeb8b16ed551053db4b0b532459d1dfdf46ab940 GIT binary patch literal 11220 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 literal 0 HcmV?d00001 diff --git a/resources/gfx/player.anm2 b/resources/gfx/player.anm2 index 8fc0187..0641124 100644 --- a/resources/gfx/player.anm2 +++ b/resources/gfx/player.anm2 @@ -27,77 +27,25 @@ "layers": [ {"name": "sprite"} ], + "nulls": [ + {"name": "Head"}, + {"name": "Face"}, + {"name": "Torso"} + ], "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"} - ] - } - ] - }, - "Preview": { - "length": 16, - "layers": [ - { - "layer": 0, - "frames": [ - {"region": 1, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, - {"region": 2, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, - {"region": 3, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, - {"region": 4, "scale": [-100, 100], "delay": 4, "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]} - ] - } - ] - } + "Idle": {"layers": [{"layer": 0, "frames": [{"region": 0}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}]}, {"null": 1, "frames": [{"pos": [0, -19]}]}, {"null": 2, "frames": [{"pos": [0, -11]}]}]}, + "Walk Left": {"length": 4, "layers": [{"layer": 0, "frames": [{"region": 1, "interpolation": "linear"}, {"region": 2, "interpolation": "linear"}, {"region": 3, "interpolation": "linear"}, {"region": 4, "interpolation": "linear"}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22], "scale": [-100, 100]}, {"pos": [0, -23], "scale": [-100, 100]}, {"pos": [0, -22], "scale": [-100, 100]}, {"pos": [0, -21], "scale": [-100, 100]}]}, {"null": 1, "frames": [{"pos": [0, -19], "scale": [-100, 100]}, {"pos": [0, -20], "scale": [-100, 100]}, {"pos": [0, -19], "scale": [-100, 100]}, {"pos": [0, -18], "scale": [-100, 100]}]}, {"null": 2, "frames": [{"pos": [0, -11], "scale": [-100, 100]}, {"pos": [0, -11], "scale": [-100, 100]}, {"pos": [0, -11], "scale": [-100, 100]}, {"pos": [0, -10], "scale": [-100, 100]}]}]}, + "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"}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}, {"pos": [0, -23]}, {"pos": [0, -22]}, {"pos": [0, -21]}]}, {"null": 1, "frames": [{"pos": [0, -19]}, {"pos": [0, -20]}, {"pos": [0, -19]}, {"pos": [0, -18]}]}, {"null": 2, "frames": [{"pos": [0, -11]}, {"pos": [0, -11]}, {"pos": [0, -11]}, {"pos": [0, -10]}]}]}, + "Preview": {"length": 16, "layers": [{"layer": 0, "frames": [{"region": 1, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, {"region": 2, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, {"region": 3, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, {"region": 4, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22], "delay": 4}, {"pos": [0, -23], "delay": 4}, {"pos": [0, -22], "delay": 4}, {"pos": [0, -21], "delay": 4}]}, {"null": 1, "frames": [{"pos": [0, -19], "delay": 4}, {"pos": [0, -20], "delay": 4}, {"pos": [0, -19], "delay": 4}, {"pos": [0, -18], "delay": 4}]}, {"null": 2, "frames": [{"pos": [0, -11], "delay": 4}, {"pos": [0, -11], "delay": 4}, {"pos": [0, -11], "delay": 4}, {"pos": [0, -10], "delay": 4}]}]}, + "Jump": {"layers": [{"layer": 0, "frames": [{"region": 5}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}]}, {"null": 1, "frames": [{"pos": [0, -19]}]}, {"null": 2, "frames": [{"pos": [0, -12]}]}]}, + "Fall": {"layers": [{"layer": 0, "frames": [{"region": 6}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}]}, {"null": 1, "frames": [{"pos": [0, -19]}]}, {"null": 2, "frames": [{"pos": [0, -11]}]}]}, + "Slide": {"layers": [{"layer": 0, "frames": [{"region": 7}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}]}, {"null": 1, "frames": [{"pos": [0, -19]}]}, {"null": 2, "frames": [{"pos": [0, -11]}]}]}, + "Kick": {"layers": [{"layer": 0, "frames": [{"region": 8}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -22]}]}, {"null": 1, "frames": [{"pos": [0, -19]}]}, {"null": 2, "frames": [{"pos": [0, -11]}]}]}, + "Hurt": {"layers": [{"layer": 0, "frames": [{"region": 9}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -10]}]}, {"null": 1, "frames": [{"pos": [0, -7]}]}, {"null": 2, "frames": [{"pos": [0, 0]}]}]}, + "Crouch": {"layers": [{"layer": 0, "frames": [{"region": 10}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -15]}]}, {"null": 1, "frames": [{"pos": [0, -13]}]}, {"null": 2, "frames": [{"pos": [0, -8]}]}]}, + "Roll": {"layers": [{"layer": 0, "frames": [{"region": 11}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -5]}]}, {"null": 1, "frames": [{"pos": [0, -3]}]}, {"null": 2, "frames": [{"pos": [0, 2]}]}]}, + "Crawl Left": {"length": 4, "layers": [{"layer": 0, "frames": [{"region": 12}, {"region": 13}, {"region": 14}, {"region": 15}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -15], "scale": [-100, 100]}, {"pos": [0, -15], "scale": [-100, 100]}, {"pos": [0, -15], "scale": [-100, 100]}, {"pos": [0, -15], "scale": [-100, 100]}]}, {"null": 1, "frames": [{"pos": [0, -13], "scale": [-100, 100]}, {"pos": [0, -13], "scale": [-100, 100]}, {"pos": [0, -13], "scale": [-100, 100]}, {"pos": [0, -13], "scale": [-100, 100]}]}, {"null": 2, "frames": [{"pos": [0, -8], "scale": [-100, 100]}, {"pos": [0, -8], "scale": [-100, 100]}, {"pos": [0, -8], "scale": [-100, 100]}, {"pos": [0, -8], "scale": [-100, 100]}]}]}, + "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]}]}], "nulls": [{"null": 0, "frames": [{"pos": [0, -15]}, {"pos": [0, -15]}, {"pos": [0, -15]}, {"pos": [0, -15]}]}, {"null": 1, "frames": [{"pos": [0, -13]}, {"pos": [0, -13]}, {"pos": [0, -13]}, {"pos": [0, -13]}]}, {"null": 2, "frames": [{"pos": [0, -8]}, {"pos": [0, -8]}, {"pos": [0, -8]}, {"pos": [0, -8]}]}]} } } diff --git a/resources/gfx/trinkets.anm2 b/resources/gfx/trinkets.anm2 index ad2d548..a67f3cd 100644 --- a/resources/gfx/trinkets.anm2 +++ b/resources/gfx/trinkets.anm2 @@ -5,72 +5,75 @@ { "path": "trinkets.png", "regions": [ - {"name": "Gloves", "crop": [0, 0], "size": [24, 24], "origin": "center"}, + {"name": "Gloves", "size": [24, 24], "origin": "center"}, {"name": "Energy Drink", "crop": [24, 0], "size": [24, 24], "origin": "center"}, {"name": "Boxing Glove", "crop": [48, 0], "size": [24, 24], "origin": "center"}, {"name": "Accelerator", "crop": [72, 0], "size": [24, 24], "origin": "center"}, - {"name": "Enlarger", "crop": [96, 0], "size": [24, 24], "origin": "center"}, - {"name": "Diazepam", "crop": [120, 0], "size": [24, 24], "origin": "center"}, - {"name": "Splitter", "crop": [0, 24], "size": [24, 24], "origin": "center"}, - {"name": "Doubler", "crop": [24, 24], "size": [24, 24], "origin": "center"}, - {"name": "Dual Wield", "crop": [48, 24], "size": [24, 24], "origin": "center"}, - {"name": "Ricochet", "crop": [72, 24], "size": [24, 24], "origin": "center"}, - {"name": "Tesla Coil", "crop": [96, 24], "size": [24, 24], "origin": "center"}, - {"name": "Recoil Coil", "crop": [120, 24], "size": [24, 24], "origin": "center"}, - {"name": "Shock Absorber", "crop": [0, 48], "size": [24, 24], "origin": "center"}, - {"name": "Shrinker", "crop": [24, 48], "size": [24, 24], "origin": "center"}, - {"name": "Embiggener", "crop": [48, 48], "size": [24, 24], "origin": "center"}, - {"name": "Pebble Helmet", "crop": [72, 48], "size": [24, 24], "origin": "center"}, - {"name": "Boots", "crop": [96, 48], "size": [24, 24], "origin": "center"}, - {"name": "Speedometer", "crop": [120, 48], "size": [24, 24], "origin": "center"}, - {"name": "Wings", "crop": [0, 72], "size": [24, 24], "origin": "center"}, - {"name": "Feather", "crop": [24, 72], "size": [24, 24], "origin": "center"}, - {"name": "Iron Weights", "crop": [48, 72], "size": [24, 24], "origin": "center"}, - {"name": "Climb Claw", "crop": [72, 72], "size": [24, 24], "origin": "center"}, + {"name": "Hourglass", "crop": [96, 0], "size": [24, 24], "origin": "center"}, + {"name": "Enlarger", "crop": [120, 0], "size": [24, 24], "origin": "center"}, + {"name": "Diazepam", "crop": [0, 24], "size": [24, 24], "origin": "center"}, + {"name": "Splitter", "crop": [24, 24], "size": [24, 24], "origin": "center"}, + {"name": "Doubler", "crop": [48, 24], "size": [24, 24], "origin": "center"}, + {"name": "Dual Wield", "crop": [72, 24], "size": [24, 24], "origin": "center"}, + {"name": "Ricochet", "crop": [96, 24], "size": [24, 24], "origin": "center"}, + {"name": "Tesla Coil", "crop": [120, 24], "size": [24, 24], "origin": "center"}, + {"name": "Recoil Coil", "crop": [0, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shock Absorber", "crop": [24, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shrinker", "crop": [48, 48], "size": [24, 24], "origin": "center"}, + {"name": "Embiggener", "crop": [72, 48], "size": [24, 24], "origin": "center"}, + {"name": "Pebble Helmet", "crop": [96, 48], "size": [24, 24], "origin": "center"}, + {"name": "Boots", "crop": [120, 48], "size": [24, 24], "origin": "center"}, + {"name": "Speedometer", "crop": [0, 72], "size": [24, 24], "origin": "center"}, + {"name": "Wings", "crop": [24, 72], "size": [24, 24], "origin": "center"}, + {"name": "Feather", "crop": [48, 72], "size": [24, 24], "origin": "center"}, + {"name": "Iron Weights", "crop": [72, 72], "size": [24, 24], "origin": "center"}, + {"name": "Climb Claw", "crop": [96, 72], "size": [24, 24], "origin": "center"}, {"name": "Gloves Small", "crop": [0, 96], "size": [16, 16], "origin": "center"}, {"name": "Energy Drink Small", "crop": [16, 96], "size": [16, 16], "origin": "center"}, {"name": "Boxing Glove Small", "crop": [32, 96], "size": [16, 16], "origin": "center"}, {"name": "Accelerator Small", "crop": [48, 96], "size": [16, 16], "origin": "center"}, - {"name": "Enlarger Small", "crop": [64, 96], "size": [16, 16], "origin": "center"}, - {"name": "Diazepam Small", "crop": [80, 96], "size": [16, 16], "origin": "center"}, - {"name": "Splitter Small", "crop": [0, 112], "size": [16, 16], "origin": "center"}, - {"name": "Doubler Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, - {"name": "Dual Wield Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, - {"name": "Ricochet Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, - {"name": "Tesla Coil Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, - {"name": "Recoil Coil Small", "crop": [80, 112], "size": [16, 16], "origin": "center"}, - {"name": "Shock Absorber Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, - {"name": "Shrinker Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, - {"name": "Embiggener Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, - {"name": "Pebble Helmet Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, - {"name": "Boots Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, - {"name": "Speedometer Small", "crop": [80, 128], "size": [16, 16], "origin": "center"}, - {"name": "Wings Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, - {"name": "Feather Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, - {"name": "Iron Weights Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, - {"name": "Climb Claw Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, + {"name": "Hourglass Small", "crop": [64, 96], "size": [16, 16], "origin": "center"}, + {"name": "Enlarger Small", "crop": [80, 96], "size": [16, 16], "origin": "center"}, + {"name": "Diazepam Small", "crop": [0, 112], "size": [16, 16], "origin": "center"}, + {"name": "Splitter Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, + {"name": "Doubler Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, + {"name": "Dual Wield Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, + {"name": "Ricochet Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, + {"name": "Tesla Coil Small", "crop": [80, 112], "size": [16, 16], "origin": "center"}, + {"name": "Recoil Coil Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, + {"name": "Shock Absorber Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, + {"name": "Shrinker Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, + {"name": "Embiggener Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, + {"name": "Pebble Helmet Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, + {"name": "Boots Small", "crop": [80, 128], "size": [16, 16], "origin": "center"}, + {"name": "Speedometer Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, + {"name": "Wings Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, + {"name": "Feather Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, + {"name": "Iron Weights Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, + {"name": "Climb Claw Small", "crop": [64, 144], "size": [16, 16], "origin": "center"}, {"name": "Gloves Tiny", "crop": [0, 160], "size": [8, 8], "origin": "center"}, {"name": "Energy Drink Tiny", "crop": [8, 160], "size": [8, 8], "origin": "center"}, {"name": "Boxing Glove Tiny", "crop": [16, 160], "size": [8, 8], "origin": "center"}, {"name": "Accelerator Tiny", "crop": [24, 160], "size": [8, 8], "origin": "center"}, - {"name": "Enlarger Tiny", "crop": [32, 160], "size": [8, 8], "origin": "center"}, - {"name": "Diazepam Tiny", "crop": [40, 160], "size": [8, 8], "origin": "center"}, - {"name": "Splitter Tiny", "crop": [0, 168], "size": [8, 8], "origin": "center"}, - {"name": "Doubler Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, - {"name": "Dual Wield Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, - {"name": "Ricochet Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, - {"name": "Tesla Coil Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, - {"name": "Recoil Coil Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"}, - {"name": "Shock Absorber Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, - {"name": "Shrinker Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, - {"name": "Embiggener Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, - {"name": "Pebble Helmet Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, - {"name": "Boots Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, - {"name": "Speedometer Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"}, - {"name": "Wings Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, - {"name": "Feather Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, - {"name": "Iron Weights Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, - {"name": "Climb Claw Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"} + {"name": "Hourglass Tiny", "crop": [32, 160], "size": [8, 8], "origin": "center"}, + {"name": "Enlarger Tiny", "crop": [40, 160], "size": [8, 8], "origin": "center"}, + {"name": "Diazepam Tiny", "crop": [0, 168], "size": [8, 8], "origin": "center"}, + {"name": "Splitter Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, + {"name": "Doubler Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, + {"name": "Dual Wield Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, + {"name": "Ricochet Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, + {"name": "Tesla Coil Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"}, + {"name": "Recoil Coil Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, + {"name": "Shock Absorber Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, + {"name": "Shrinker Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, + {"name": "Embiggener Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, + {"name": "Pebble Helmet Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, + {"name": "Boots Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"}, + {"name": "Speedometer Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, + {"name": "Wings Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, + {"name": "Feather Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, + {"name": "Iron Weights Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"}, + {"name": "Climb Claw Tiny", "crop": [32, 184], "size": [8, 8], "origin": "center"} ] } ], @@ -83,67 +86,70 @@ "Energy Drink": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, "Boxing Glove": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, "Accelerator": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, - "Enlarger": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, - "Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, - "Splitter": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, - "Doubler": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, - "Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, - "Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, - "Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, - "Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, - "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, - "Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, - "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, - "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, - "Boots": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, - "Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, - "Wings": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, - "Feather": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, - "Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, - "Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, - "Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, - "Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, - "Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, - "Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, - "Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, - "Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, - "Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, - "Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, - "Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, - "Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, - "Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, - "Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, - "Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, - "Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, - "Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, - "Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, - "Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, - "Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, - "Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, - "Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, - "Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, - "Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, - "Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, - "Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, - "Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, - "Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, - "Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, - "Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, - "Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, - "Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, - "Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, - "Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, - "Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, - "Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, - "Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]}, - "Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]}, - "Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]}, - "Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]}, - "Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]}, - "Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]}, - "Wings Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]}, - "Feather Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]}, - "Iron Weights Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]}, - "Climb Claw Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]} + "Hourglass": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, + "Enlarger": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, + "Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, + "Splitter": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, + "Doubler": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, + "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, + "Boots": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, + "Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Wings": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, + "Feather": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, + "Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, + "Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, + "Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, + "Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, + "Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, + "Hourglass Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, + "Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, + "Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, + "Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, + "Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, + "Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, + "Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, + "Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, + "Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, + "Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, + "Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, + "Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, + "Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, + "Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, + "Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, + "Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, + "Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, + "Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, + "Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, + "Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, + "Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, + "Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, + "Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, + "Hourglass Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, + "Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, + "Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, + "Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, + "Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, + "Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, + "Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]}, + "Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]}, + "Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]}, + "Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]}, + "Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]}, + "Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]}, + "Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]}, + "Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]}, + "Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]}, + "Wings Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]}, + "Feather Tiny": {"layers": [{"layer": 0, "frames": [{"region": 66}]}]}, + "Iron Weights Tiny": {"layers": [{"layer": 0, "frames": [{"region": 67}]}]}, + "Climb Claw Tiny": {"layers": [{"layer": 0, "frames": [{"region": 68}]}]} } } diff --git a/resources/gfx/trinkets.png b/resources/gfx/trinkets.png index fcb80998c73cc594e7806a0c5e4273e9a40baabf..1c519ae4f8a98d1b5077b44b484cf707d411a597 100644 GIT binary patch literal 6890 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 FV1jbaQcnN? diff --git a/resources/gfx/weapon_icons.anm2 b/resources/gfx/weapon_icons.anm2 index ccb0f7a..30c59b4 100644 --- a/resources/gfx/weapon_icons.anm2 +++ b/resources/gfx/weapon_icons.anm2 @@ -5,53 +5,62 @@ { "path": "weapon_icons.png", "regions": [ - {"name": "Sword", "crop": [0, 0], "size": [24, 24], "origin": "center"}, - {"name": "Hammer", "crop": [24, 0], "size": [24, 24], "origin": "center"}, - {"name": "Lance", "crop": [48, 0], "size": [24, 24], "origin": "center"}, - {"name": "Fist", "crop": [72, 0], "size": [24, 24], "origin": "center"}, + {"name": "Fist", "size": [24, 24], "origin": "center"}, + {"name": "Sword", "crop": [24, 0], "size": [24, 24], "origin": "center"}, + {"name": "Hammer", "crop": [48, 0], "size": [24, 24], "origin": "center"}, + {"name": "Lance", "crop": [72, 0], "size": [24, 24], "origin": "center"}, + {"name": "Whip", "crop": [96, 0], "size": [24, 24], "origin": "center"}, {"name": "Flail", "crop": [0, 24], "size": [24, 24], "origin": "center"}, - {"name": "Whip", "crop": [24, 24], "size": [24, 24], "origin": "center"}, + {"name": "Boomerang", "crop": [24, 24], "size": [24, 24], "origin": "center"}, {"name": "Grapple", "crop": [48, 24], "size": [24, 24], "origin": "center"}, - {"name": "Boomerang", "crop": [72, 24], "size": [24, 24], "origin": "center"}, - {"name": "Bow", "crop": [0, 48], "size": [24, 24], "origin": "center"}, - {"name": "Uzi", "crop": [24, 48], "size": [24, 24], "origin": "center"}, - {"name": "Shotgun", "crop": [48, 48], "size": [24, 24], "origin": "center"}, - {"name": "Blaster", "crop": [72, 48], "size": [24, 24], "origin": "center"}, - {"name": "Flamethrower", "crop": [0, 72], "size": [24, 24], "origin": "center"}, - {"name": "Rocket Launcher", "crop": [24, 72], "size": [24, 24], "origin": "center"}, - {"name": "Mine", "crop": [48, 72], "size": [24, 24], "origin": "center"}, + {"name": "Bow", "crop": [72, 24], "size": [24, 24], "origin": "center"}, + {"name": "Blaster", "crop": [96, 24], "size": [24, 24], "origin": "center"}, + {"name": "Uzi", "crop": [0, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shotgun", "crop": [24, 48], "size": [24, 24], "origin": "center"}, + {"name": "Bolter", "crop": [48, 48], "size": [24, 24], "origin": "center"}, + {"name": "Rocket Launcher", "crop": [72, 48], "size": [24, 24], "origin": "center"}, + {"name": "Grenade Launcher", "crop": [96, 48], "size": [24, 24], "origin": "center"}, + {"name": "Mine", "crop": [0, 72], "size": [24, 24], "origin": "center"}, + {"name": "Flamethrower", "crop": [24, 72], "size": [24, 24], "origin": "center"}, + {"name": "Vacuum", "crop": [48, 72], "size": [24, 24], "origin": "center"}, {"name": "Grimoire", "crop": [72, 72], "size": [24, 24], "origin": "center"}, - {"name": "Sword Small", "crop": [0, 96], "size": [16, 16], "origin": "center"}, - {"name": "Hammer Small", "crop": [16, 96], "size": [16, 16], "origin": "center"}, - {"name": "Lance Small", "crop": [32, 96], "size": [16, 16], "origin": "center"}, - {"name": "Fist Small", "crop": [48, 96], "size": [16, 16], "origin": "center"}, + {"name": "Fist Small", "crop": [0, 96], "size": [16, 16], "origin": "center"}, + {"name": "Sword Small", "crop": [16, 96], "size": [16, 16], "origin": "center"}, + {"name": "Hammer Small", "crop": [32, 96], "size": [16, 16], "origin": "center"}, + {"name": "Lance Small", "crop": [48, 96], "size": [16, 16], "origin": "center"}, + {"name": "Whip Small", "crop": [64, 96], "size": [16, 16], "origin": "center"}, {"name": "Flail Small", "crop": [0, 112], "size": [16, 16], "origin": "center"}, - {"name": "Whip Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, + {"name": "Boomerang Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, {"name": "Grapple Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, - {"name": "Boomerang Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, - {"name": "Bow Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, - {"name": "Uzi Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, - {"name": "Shotgun Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, - {"name": "Blaster Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, - {"name": "Flamethrower Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, - {"name": "Rocket Launcher Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, - {"name": "Mine Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, + {"name": "Bow Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, + {"name": "Blaster Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, + {"name": "Uzi Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, + {"name": "Shotgun Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, + {"name": "Bolter Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, + {"name": "Rocket Launcher Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, + {"name": "Grenade Launcher Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, + {"name": "Mine Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, + {"name": "Flamethrower Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, + {"name": "Vacuum Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, {"name": "Grimoire Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, - {"name": "Sword Tiny", "crop": [0, 160], "size": [8, 8], "origin": "center"}, - {"name": "Hammer Tiny", "crop": [8, 160], "size": [8, 8], "origin": "center"}, - {"name": "Lance Tiny", "crop": [16, 160], "size": [8, 8], "origin": "center"}, - {"name": "Fist Tiny", "crop": [24, 160], "size": [8, 8], "origin": "center"}, + {"name": "Fist Tiny", "crop": [0, 160], "size": [8, 8], "origin": "center"}, + {"name": "Sword Tiny", "crop": [8, 160], "size": [8, 8], "origin": "center"}, + {"name": "Hammer Tiny", "crop": [16, 160], "size": [8, 8], "origin": "center"}, + {"name": "Lance Tiny", "crop": [24, 160], "size": [8, 8], "origin": "center"}, + {"name": "Whip Tiny", "crop": [32, 160], "size": [8, 8], "origin": "center"}, {"name": "Flail Tiny", "crop": [0, 168], "size": [8, 8], "origin": "center"}, - {"name": "Whip Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, + {"name": "Boomerang Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, {"name": "Grapple Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, - {"name": "Boomerang Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, - {"name": "Bow Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, - {"name": "Uzi Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, - {"name": "Shotgun Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, - {"name": "Blaster Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, - {"name": "Flamethrower Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, - {"name": "Rocket Launcher Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, - {"name": "Mine Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, + {"name": "Bow Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, + {"name": "Blaster Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, + {"name": "Uzi Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, + {"name": "Shotgun Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, + {"name": "Bolter Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, + {"name": "Rocket Launcher Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, + {"name": "Grenade Launcher Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, + {"name": "Mine Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, + {"name": "Flamethrower Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, + {"name": "Vacuum Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, {"name": "Grimoire Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"} ] } @@ -59,55 +68,64 @@ "layers": [ {"name": "sprite"} ], - "defaultAnimation": "Sword", + "defaultAnimation": "Fist", "animations": { - "Sword": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, - "Hammer": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, - "Lance": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, - "Fist": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, - "Flail": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, - "Whip": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, - "Grapple": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, - "Boomerang": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, - "Bow": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, - "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, - "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, - "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, - "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, - "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, - "Mine": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, - "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, - "Sword Small": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, - "Hammer Small": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, - "Lance Small": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, "Fist Small": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, - "Flail Small": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, - "Whip Small": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, - "Grapple Small": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, - "Boomerang Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, - "Bow Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, - "Uzi Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, - "Shotgun Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, - "Blaster Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, - "Flamethrower Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, - "Rocket Launcher Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, - "Mine Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, - "Grimoire Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, - "Sword Tiny": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, - "Hammer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, - "Lance Tiny": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, - "Fist Tiny": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, - "Flail Tiny": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, - "Whip Tiny": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, - "Grapple Tiny": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, - "Boomerang Tiny": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, - "Bow Tiny": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, - "Uzi Tiny": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, - "Shotgun Tiny": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, - "Blaster Tiny": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, - "Flamethrower Tiny": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, - "Rocket Launcher Tiny": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, - "Mine Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, - "Grimoire Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]} + "Fist Tiny": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, + "Sword": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, + "Sword Small": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Sword Tiny": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, + "Hammer": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, + "Hammer Small": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, + "Hammer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, + "Lance": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, + "Lance Small": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, + "Lance Tiny": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, + "Whip": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, + "Whip Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, + "Whip Tiny": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, + "Flail": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, + "Flail Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, + "Flail Tiny": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, + "Boomerang": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, + "Boomerang Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, + "Boomerang Tiny": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, + "Grapple": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, + "Grapple Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, + "Grapple Tiny": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, + "Bow": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Bow Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, + "Bow Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, + "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Blaster Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, + "Blaster Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, + "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Uzi Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, + "Uzi Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, + "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Shotgun Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, + "Shotgun Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, + "Bolter": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Bolter Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, + "Bolter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, + "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Rocket Launcher Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, + "Rocket Launcher Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, + "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Grenade Launcher Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, + "Grenade Launcher Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, + "Mine": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, + "Mine Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, + "Mine Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, + "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, + "Flamethrower Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, + "Flamethrower Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, + "Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, + "Vacuum Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, + "Vacuum Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, + "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Grimoire Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, + "Grimoire Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]} } } diff --git a/resources/gfx/weapon_icons.png b/resources/gfx/weapon_icons.png index d377804be5aa65026bf21c0be01f48174bf756b6..bda2724b8a987650f97463aa53b7efdd8beeddf4 100644 GIT binary patch literal 5484 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 diff --git a/resources/gfx/weapons.anm2 b/resources/gfx/weapons.anm2 index 65c1ca0..a94bab4 100644 --- a/resources/gfx/weapons.anm2 +++ b/resources/gfx/weapons.anm2 @@ -5,51 +5,72 @@ { "path": "weapons.png", "regions": [ - {"name": "Sword", "crop": [8, 10], "size": [32, 12], "pivot": [4, 5.5]}, - {"name": "Hammer", "crop": [57, 9], "size": [29, 14], "pivot": [4, 7]}, - {"name": "Flail", "crop": [107, 5], "size": [30, 19], "pivot": [3, 9]}, - {"name": "Lance", "crop": [145, 10], "size": [47, 12], "pivot": [6, 6]}, - {"name": "Whip", "crop": [11, 41], "size": [24, 22], "pivot": [7, 3]}, - {"name": "Whip Base", "crop": [154, 105], "size": [15, 12], "pivot": [8, 3]}, - {"name": "Grapple", "crop": [60, 41], "size": [27, 13], "pivot": [3, 6.5]}, - {"name": "Boomerang", "crop": [111, 41], "size": [19, 13], "pivot": [9, 2]}, - {"name": "Bow", "crop": [161, 35], "size": [14, 27], "pivot": [2, 13]}, - {"name": "Blaster", "crop": [11, 74], "size": [23, 14], "pivot": [4, 9]}, - {"name": "Uzi", "crop": [61, 72], "size": [24, 18], "pivot": [10, 8]}, - {"name": "Shotgun", "crop": [104, 74], "size": [31, 10], "pivot": [8, 5]}, - {"name": "Flamethrower", "crop": [151, 72], "size": [31, 17], "pivot": [5, 9]}, - {"name": "Rocket Launcher", "crop": [8, 106], "size": [31, 12], "pivot": [10, 5]}, - {"name": "Mine", "crop": [66, 103], "size": [10, 18], "pivot": [1, 11.5]}, - {"name": "Grapple Tip", "crop": [70, 41], "size": [17, 13], "pivot": [0, 6.5]}, - {"name": "Fist", "crop": [111, 105], "size": [14, 14], "pivot": [0, 7]}, - {"name": "Grapple Base", "crop": [60, 41], "size": [10, 13], "pivot": [3, 6.5]}, - {"name": "Grimoire", "crop": [10, 134], "size": [16, 9], "pivot": [8, 2.5]} + {"name": "Fist", "crop": [17, 9], "size": [14, 14], "pivot": [0, 7]}, + {"name": "Sword", "crop": [56, 10], "size": [32, 12], "pivot": [4, 5.5]}, + {"name": "Hammer", "crop": [105, 9], "size": [29, 14], "pivot": [4, 7]}, + {"name": "Lance", "crop": [144, 10], "size": [47, 12], "pivot": [6, 6]}, + {"name": "Whip", "crop": [12, 37], "size": [24, 22], "pivot": [7, 3]}, + {"name": "Whip Base", "crop": [64, 42], "size": [15, 12], "pivot": [8, 3]}, + {"name": "Flail", "crop": [105, 38], "size": [30, 19], "pivot": [3, 9]}, + {"name": "Boomerang", "crop": [158, 41], "size": [19, 13], "pivot": [9, 2]}, + {"name": "Grapple", "crop": [10, 73], "size": [27, 13], "pivot": [3, 6.5]}, + {"name": "Grapple Tip", "crop": [20, 73], "size": [17, 13], "pivot": [0, 6.5]}, + {"name": "Grapple Base", "crop": [10, 73], "size": [10, 13], "pivot": [3, 6.5]}, + {"name": "Bow", "crop": [63, 66], "size": [14, 27], "pivot": [2, 13]}, + {"name": "Bow Charge 1", "crop": [103, 66], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Charge 2", "crop": [151, 66], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Charge 3", "crop": [7, 98], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Charge 4", "crop": [55, 98], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Cooldown 1", "crop": [103, 98], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Cooldown 2", "crop": [151, 98], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Cooldown 3", "crop": [7, 130], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Bow Cooldown 4", "crop": [55, 130], "size": [33, 27], "pivot": [8, 13]}, + {"name": "Blaster", "crop": [108, 137], "size": [23, 14], "pivot": [4, 9]}, + {"name": "Uzi", "crop": [156, 135], "size": [24, 18], "pivot": [10, 8]}, + {"name": "Shotgun", "crop": [8, 171], "size": [31, 10], "pivot": [8, 5]}, + {"name": "Bolter", "crop": [59, 169], "size": [25, 14], "pivot": [6, 7]}, + {"name": "Bolter 1", "crop": [106, 169], "size": [28, 14], "pivot": [6, 7]}, + {"name": "Bolter 2", "crop": [154, 169], "size": [28, 14], "pivot": [6, 7]}, + {"name": "Bolter 3", "crop": [10, 201], "size": [28, 14], "pivot": [6, 7]}, + {"name": "Bolter 4", "crop": [58, 201], "size": [28, 14], "pivot": [6, 7]}, + {"name": "Bolter 5", "crop": [106, 201], "size": [28, 14], "pivot": [6, 7]}, + {"name": "Rocket Launcher", "crop": [152, 202], "size": [31, 12], "pivot": [10, 5]}, + {"name": "Grenade Launcher", "crop": [8, 233], "size": [31, 14], "pivot": [12, 7]}, + {"name": "Mine", "crop": [67, 231], "size": [10, 18], "pivot": [1, 11.5]}, + {"name": "Flamethrower", "crop": [104, 231], "size": [31, 17], "pivot": [5, 9]}, + {"name": "Vacuum", "crop": [146, 229], "size": [43, 22], "pivot": [23, 7]}, + {"name": "Grimoire", "crop": [16, 267], "size": [16, 9], "pivot": [8, 2.5]} ] } ], "layers": [ {"name": "sprite"} ], - "defaultAnimation": "Sword", + "defaultAnimation": "Fist", "animations": { - "Sword": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, - "Hammer": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, - "Flail": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, + "Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, + "Sword": {"layers": [{"layer": 0, "frames": [{"region": 1}]}]}, + "Hammer": {"layers": [{"layer": 0, "frames": [{"region": 2}]}]}, "Lance": {"layers": [{"layer": 0, "frames": [{"region": 3}]}]}, "Whip": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, "Whip Base": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, - "Grapple": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, - "Grapple Tip": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, - "Grapple Base": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, + "Flail": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, "Boomerang": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, - "Bow": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, - "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, - "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, - "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, - "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, - "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, - "Mine": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, - "Fist": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, - "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]} + "Grapple": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Grapple Base": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Grapple Tip": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Bow": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Bow Charge": {"length": 4, "loop": false, "layers": [{"layer": 0, "frames": [{"region": 12}, {"region": 13}, {"region": 14}, {"region": 15}]}]}, + "Bow Cooldown": {"length": 4, "loop": false, "layers": [{"layer": 0, "frames": [{"region": 16}, {"region": 17}, {"region": 18}, {"region": 19}]}]}, + "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, + "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, + "Bolter": {"length": 25, "layers": [{"layer": 0, "frames": [{"region": 24, "delay": 5}, {"region": 25, "delay": 5}, {"region": 26, "delay": 5}, {"region": 27, "delay": 5}, {"region": 28, "delay": 5}]}]}, + "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, + "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, + "Mine": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, + "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, + "Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, + "Grimoire": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]} } } diff --git a/resources/gfx/weapons.png b/resources/gfx/weapons.png index 6a26326eabd1680315b2ca569fc3510036fb6e51..af18fc99497d773ec8d5eaf3dd03de0b6a264991 100644 GIT binary patch literal 4749 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 literal 11080 zcmY*9x|E9H_7*q z2n9KDBzQb{004j_DFIaa&-eYO@vzYUSv@6_8vuX|#Zp8>!P3|m0I&$C&Qw?VA=~)u zWp<31mCcbMJI!^RO$h^+mCcrz6i7|W6-g}#2?YsM6vUhi&ku~uF9e5<_Al%kb;@mA z_F|lFf45q8{w^!6>L^{Vy@m$tC5QWuxu?VVkCg{=inEDiW_7|KHNwIIP{RG;7eB%G zjVX$RmHWOe@#VoDW_Zc(Cwv{uz`c6@2ZsVIfv<3F|Tz303OSWpT+lg0fg{A85pYT zwl>q99n!aH*R7XqydeEh7~Y^4NdkY>~zrqyBp`Lx5z91;i_ zxmLxy+0g|5k|o0@#HN4V(H@?7EgK(t|9eRpA7udfE(!^RVLfU1e%L3r{l1*`bB{(* zQu<)R2L6HsG}m>$i(mp|Z~+^mZf@VdF0X9!whs)6{O8KQhs5*11$}&G{5}Nu6T|@2 zuK>i&kGPp$cz@|Iz^*q|JWkp}ZQNXAg1J2jLE8w+rDNutSsqMuG}? z$%5A~YW70ZnLq*$3|i&WJ9sXa1^-I|RUG&$9+_3}=NL2>3HD(CxIHQXct}1hW)S%< zH8t31Kb!*mxFA$Hq-s9iVj#By3Z5W2CsbyiAe{xoWj;hUL{2_}4qz)E<{^-O z76rPG(jHd_Ok|f92b$afQwPR)_a_bv#x7PH#8sfzE`K^CN6E<2B5aj`UbiS>blRX4qUAt<_69iTE9>I2I&JJ&=0)|+1rQp0@2csyc$9R8yb%a zKOm?9i-Jai4h*G6Wdnw2kp2#b*bi3jkhlji7pgp#>&hkgYJrtj!6dGjV2cX};wN z%L8U+P_D@REcgSSR^YbK#4POt+8d%zK#!0>zRL-A6Vzvjs{vq;8GWRWGaZm*0521w zY3Q1c@h|k%089fFD^%1FP6ItP)YK4415Pbq$xv1Ue>nunfP5A5cNl}A^(wNfe~tn6 zD$NZeqFJ(LdA%D|&N z`6eXCVD*jW3xPM$RVdTI=M4!jq<)b29*a9c57JHuaUaT_K|A+NR&WgArV;eAynt!U&2@g{t9B{ zq`%O(Vflhd2Ote&>x5Uy2vDbCv4eRA5cZ;O#FMqxcN+6Jq&q}+#C-*85B-SM z5&sgiEdszt3>3mr!$(35$L@>W76mI5Qgcf|m-s7*RTMQTzEP7WP3A z=ex{>S|~SBu47#WyNY@h`pkiypgKcxL+AL*iJIp-%|e|Fo}fF^binI|*#bF=Hs*3p zh@1u9SUqw0LURRoLeJ@7f%%tfOGW0*;l0yFu92!S5i?fuZ)5;M3!z_hz z!g3;VLUV$1`r$;#7SJB$5$+M~5$X})5u`cdJ>WeEGHzk+)S$o$qcp70K&EF>L!}E% z85lQAsmIgM1)(hySO>Wbbr^Nm`L2@u;rtDq70%~BHSo_ks*ZA%^$&Xsm>1qg=;hFp z0Zg69s^SIb6XO%<6U!5>E806&XQb}nSKrrw?Exw$eQa>iKBv7mCuugP9A25f@}T7b zqJ1f+bvDE_)N#c9Ak^M~Lwy^C4jv)IVPe9es(q5*0b(>Qg(I()E_KMpU+>Q7d zmLn=_w7Z|-fd8Q9z<00ZmYFDuYESb<$KAIJHa9kHknVu}*3%uMi*lRjI`DbueJ|(+ z?S<2ul^-H6jABZ%P7#!G# z8V*StkkluSN-BYV2ulme5D3;EZ6IP#)`sB?7ZA)Wj7S<0FE>Vsi7o|u6s$EsBTQG2 zG%Jlmfrw#?euZ8ND-U-c%n__QfIYyyhk{0C6iz{gkPsz79*I^G5ealDWSk4O5OpT$ zK<|zS7-BX;WQj-@nk2_Ui$kM}cpO?7a@~hA5^xCT6zh`Brr;;-A?f*9gSrxaJ|sX9 z9!!}a`Gw{oaE0keFp{drpZ_%c}zgW?cMl|^#*-DDMBxPRovtEEQ$fWV4TSi$%9!sy8p6S>4M-+Uy25rc=(2x8`Wz&jE z72QgjrPPkNGg(sw@`{K>L@WMB*ye04d7DyKrT2363XY2Bint1DCQ{4DD)_-D3&PpD7GkUX%cBjX>e(7Y5obcRL4~4RIF6SRNqu<`U83tjob$M zx-N4~<28dd)8RTL4K58!5IX1%bOwS3VS$iAM4&H_A?Oxl3Gx7mYfNaKYietBYQSiO zX;NtrH%vG580i{*j=W_d84WfnuDP!UuNAK)uQ{*Eto~WkS@T{KUNu@(T60`QZcM3P zs%LL>X?SczYLuK=Xm?Hmj{D!RM9i@2w` zvAoT=S-bbRfw~jDFS$FqO}SCM&-r)0-#O%#bIre_+_U0M{JQ)KCXkyKqwlYusGp&q zwiCD$u@fc`ERZV@)EnO$-5b!G)0_5<_s#HS`Q`Ct`gQ%`2gMAo0;UJ%0454%1I7j> z4#pH<5WpGW93TQ-0*(Z31Kth30%?mNgV2Skf%ZaLCo(v%Ve#>cTQ;Lqys?;T!Q0ZX z38?kEI$cfDy3l2wac;x{#lq>olZSWNN}?1`HpW0rOU+bHX+e8Jazcqik3*GCqd}p; zxJtcBy2`{!>#lFFb`!H}-mfn#K^lqL8ebmY72gt{5nmJ^r%wh7 zKZ{flCOi|ejo^dvMi>3V3=Io46;loEH);!(D+V%ZEEYeBx57)XeUN>pecny@4c}hb zfL-uPuwPU@FdgVFN&!p;J^(>L9pHy(Ay7#a9w-;S7}XT@5D^`|iIMR`6I~Ny4P7)! zN%U3pw2-zCy6|`5RUzUW&Ybg{@SNkEa$!zkP$7qC8Sq#%P1G;^DiUpob0~QDaHwf$ zawvPqdw5_tarkaHHfkxVIqESYCUOfq3&V@bVqb8^nd8n=aDlM)V-1Nbn4poMH@hjjG&^IH zpUbZur(LGqs2#8UcRPPOOFK<_nj`U%??lm&=26Ju(V@#>_2Jf0&=JO=(^1?+Uh;Kv zFTJ1iSHwr+2gj=}R3dn5U~ph%V0~cGfNuzOh;zsw{1SXMybruFyfQojd>&>CW_{#E zBw1u+TZ)G%Xmd1MM2^s0yA6w`!=$RrzEEeHEyROjT0_O7%$fQN>2JTV+rc zr5vk5t)iv!scKs#N71MHP1Qv`1~gLkv%0jztl(cM7AUn?Enlt1RjgY5vSU4Yy>LBq zy@gvbM_gA)S7b|POO;Q8Pmxc*TjsOLGw#jhk>*kKk^RvIsSr9YG&!_1G$b@jtWK<9 zL?{M6#w>>X5?nU2)V~x|8e1Aunr7B!mifyK8U*0|P)mYf!tmV;Ki7FAPbQ%AE{b7>P+bC1QPMN;!?vuTq{lTA~16M54@b5!$r z(~PD5Tu;@f&U?_49bq- z?rHVe=kn;O)2Z~C^_ljW*GbrU*(vt<%DLN_{*qt*x6*gxS1j0?|Bm3a;7s4NL9bA> zP;5V3&;oQfGzX#?;x^vLFCcCMZX|9cUM6k_UMNl}4i@e*o+}PA?lxOC$H$*G8{ltr z=0vu64oBuYwn(;dHd+p=-)9_|%$e+3zu!!w+5Y`bWnE@%`Tg`emUWty-*(ev)0)?e z*Z5=EeGAfNz>v(a(9pK%T zlb2JUlLu4xQ*=`slT2AV?0FpDe|oI@O-M}&Obm_ojL?i6`U+PI`}ezs%ZHo(+h`q> z>xm0W^GXx*Bl9c&b-vQD*|2n%ctkjXotnqsR-FzC1|?U^RBwWWtLH(nTI^bn8e4Uq zM$La7H}LGHtQoAU?Jg|{EpkkBEp)B7EcyQMnf}L1%8=GWC&b3ZMn|ScW`1s$_LYwR z15K%BNoJ}4-myt&A!u>dX3)mh*5BaV@ZONxXlxN_LHUDi)4M@>xq5lxs^!|^`hK~8 z0e?|>!F^G)F1y~{@@^Q{a@>^Guu|XJ`rIntBw(U%y<;k1Bw+Ks?7IMV+;>iLY_Pj~ zfqD^tWdpdrW_aFun6!@Z@ln zv|iRZV@PbQKi;$NL-qW4fxpAidVSDCh+P>~9@P<*5>+dmEgc{oBV9HTJ`p|PmByf+ zNH?Xrwlul4z&_5t(me18I#*qxS)p8!<>VS9eX z>BjHo;b!c{@ofK0|IGUQ`B?@CgV&zOn+TrhC(#uV8qpfj5z!@)I$tU8w&%wk zg*&`Etw*MZr>B_LKMysJ+|33uj|0(L^e3hJhP(CalwH*m)$658mj&zT^CXVL zseiK1%eSsKX70o{jJN!ke7im~UUTnZD9d3h;@hLEqgzz-R4ZlkWrOBJ<|F27$F)pJ zwdJ*;wbiw~b`5s**EBo)J3Kv1KE2oC*T-v2tvl|$8((9e?eBEYc^d)^-?v{&pBug^ z0uBNU0$u{4J7B%&y==Zgz9=7h?=~+OAC_OVUszxKU$0oIsA#B3Xm9vn@HL1M@JEPes8A>~C^bk|$XG~zWF4eU!#d^@MBPvf z^5BGiYU3uh8kSOAy68(1t9bW#xp-p*dj&BCO9k!O=KRzAl>AErt2$tvT%EGLguR)) z=FQsf-tK6BRevWbh%}MZlk{D9K)6AKR%ALv2gL*Ljs_F?2sQCQDYSXE*|;0L1l%0#Rqv?}mJO;8 zxRGg)Q;yVL=Ig(eBPm6CxyA$2AqZ9tcufViKJH^ky(Ii;I?ef{l77s(~X zD@8RNlQ}8dfk3~p{dzf zgW)u2Eab`);HXig3Lo5Ox-m@?!VqCk{e0wsPEC+Zi4r-Dq8?yxIDPcMtk_#%a# z*}~ag=IXk_8}Gg90re1VQF_sN(NfVKQ5sRI$mt(j^jg&GypzrvcXe}y*MpK_afJL{ z?iV*7dyhs^^}u?=dUFRO2T2F7TaDYO{YUaca&&RTICmmRUdIcG_k?=#d-3HsrP%p6 zJfcVLOy`G}xkhn20v`|O#^82fS3+qXHHV9V zdY;kLSR3MF&Kmp8GtL9|<=gcGpHZr~+H8OMn_LAxLqD<)V1L92Mw6Sekxs#82X8rVSyzpBzIWw^ zF;r!sT7hbPEm`Tly{PoC#qfRPqm)#MNr_~M-BG}JI z(#nF$GS!{Rh^76d(53CAze^=cx=XE9X_anPZC1(F7ghsS@K$n5h)cZHuWHhuAGPeD z#|qhMhT3iguhu7B!t#S^U>Jqc5QPT@SWE zvY5ZHyB$Ak-?gVR2pr4`y-d=fN|9gBLlp2hIXq1oq{_?Fu`{tvw*6&?XfbI8vL>*| zw$#-%VbrNLD^7>yY2#Y2FOBK)0Cx<7gRaO>22(k!j(%)#Q_T-ZX9*wvbR3 zCD)qWX=i4eYT068_Urgp-k+ZNkET~Igm%$} zVGw0LWmQRyY2A^-#EO~`UH4M)QZ0L9bBjy9OX;IDet~S^RGW>TwwSi;hTw+tMw+W| zC$Q6@Q{PL>%jRA1UFJQlaj5a3@u&Icj9G3q50CrP?V26oA@X13#@N2ti`dXuI>JUm zfoYbUBrbCg#uuIKcID%m94}wn#-wKDo`q62(7l4W)A8eU$h3fNwl1x1tnQqyc*nG> ztTRIn%<=2Ab*>vUx4!+pC(2xDyfhsqNZlJeL*~0Ab zyLUc5y(w*@@>UcLrCb6`GDoQYp}$Gq+D`fq|lV0eLSzm(gR%gStJ82E&F(LGE)!f(rW6(@=! z#IMU(7WNm+7DyLD7R<}8W&G0nXC$UHW`L6fIj5N!*_vK59`xU4^_Dph&tJYKJc$K>~lW#!s3TzPqZLXQqk1T!deBy$L}dvk3wDzmiXI;RJ& ziT0azawlD!x5VbaC=98YrNjxjlRb5 z(XnzJ8@#R=ugu)Mn^s^~Cz=zKXs4e4pCW9%wzY6WPJ)5!n&#$?`+_*1PLI zeowmY^}YF+ec$^o+8tdZe)jYJ{ClVTST;HnohK_`_obsr1)8YlsiCS~0qJY|H3w{> z?$oV^Z&PpKth%?lrCf#(x{G^{T2XnHd7Imt`(j^2UTIy3U8PJo}=C^!5007t>NuZF5 z`}$=Lg3~4~qS&q6m)nMZF8&M>t|}BgGs+&H`N)<@C(j;fK;Mfp?0;4Mm&hOAN4ePr zL9q3`#4&9*6Y8Z8yyJc63HYVM?RFyy+`T_v_v(>1p{K`Ck1~$rz31rJ{(_F)k=M=^ z4IhpV_5JeHbA+C~j-mfHzA!DDt98pMnH#fi0b3&LWt_-){iZS9@jc|>(UmXY;Q8yr zQ;aBJC{q(Hkjiq@SN|=>ecM8sW~$P@#-U75;Ix)lbW9I38>{ZQgrFx4ySNY89LqUi z7P_ixxxNyU29E$zx5c_O`EY2t-v>}0q!oQG`KNf+j=)*P=(ifrz!ol}@q=Q%RmV!@ zqITXN&sS!=9KJPu#q{nP`A!{r#an}FCS;sDPtWN$)>7|OQ#&8@E`l<%Opc$GT$McK zQ(M!vj(W&_)_5iOETP+!dzAQge2@&*2qdO*`7TQTSvAyBfxyH<1>41*8VGrevCApH zS`mG1m%-&%q41hvbGabgdr)Q@kXMb+CVl)n6vnN2Ks^{AV>~n0o>cBfF?HKwoC`QR zHhtb82;QU6ZTPUqWieiq+1Y^5%Vpy-&#wD>fIl0={~gW$@qKK&sF-aeE{v8Vq=fPF-=HSQ@SC%_#p$oE%4VfRcroc(`U)PHzq zki;?Do>e5B{DIi#Q{=(-W+83AVUx+51<4(ScYA z7>c#3KHC~tX<(=$K0*UM9VPKyns3W6^SikNhQZ8^4BCzWTn95Q^YB#EQvg!(6`K@w zeNzLPuqj=l!2-IH4jTdzoL{Wc6^?3Lg47mcVbO_>mL|HUAL^%JU7;d$g!ym$g4U(! zIW3zHUT@=)LtsMa_%K2nc;PX8x(VB)C%U zt56+yueGjWUFDi~^{{g6#f&F8?0uDY+K8NG*1hZQQV0&`!fTBXZ5zpcB$bXDfZSeCAFj#r^zW>FsqKdJ25Qd}IpnM^BbvRd)oDPih@-ITHWDfa{E7zd=H z#uY0R#P=NvR+UO~knctD6MyeCeh`y$7au~txyh*s_os&9eOJCLy0CKHpn*?ySU%&O zbVpw(u&<9^Jh6#?t?Wt>HtgPVo@**9N}7Sa!1%CTqbci*Wp@>K!Ud?=%*c8C`P7CZ0s3Kmevgd);c>>&960B3-jJ@^WJ>O_2*$G#}^DQq6*f(yTQ>|gw7 zA@gkO@%XDShuAUh?y}+yN2REH$&!wk^ZW1LQ)$W1_=KW3ctVE9ual*d&Jc`-NhPU~XqDWc_&Efm zAAxP;XUWzb*XMcWHC`;^H`Wg_3?8_t8LDz3JDnKCi@C~)`)V+-5X29)2{s^x_Y@H~ zg3}}yNgDOzag)!@Us+P@UCVR5p1MbnYVG$+Z2klc%}II{I%$X!`12BfM;B$5&OjBD zA|bB0tq2rOW?QOJSX_o>RkX)OGi=L?-)T{b;*}y3oLrSZ590%k8(?W%tfM$JYdwpN zKIt5_l-}4MyVu2S;?xdkRL9OR>GD3(It3hAGS~`V^yG+qfUgY5B&O5Kj%0M$-RdK? zh)=|2DR~h)9LqM*Tu1w@Jh$IxA$8F^B0upbRppv+k7Ppr5Mj5cvK!fcCL=gl{jt^x zd06Uz*xMx%v_&(uddVr#C&XZ4F!Y(ue2WreaneQUac!zqwF<&&>@S@AsE*izJ0 zs_#!uuCk^iI9U(lKnb(E-}lMhRA+2#1vz5H7O9zkyHA10MFQ!`+=PAZG8$*!gDcA3 zzRF2Bf4*`|l&qHNWnl)tM(Z}%|A(Oe9o!&@%lse+83AMV-ICz!H-62BON1sSGi#lFjatq;`3n&ic%F{zNB_?gKm5K8ZXA> zg1I??q@+Ao;qh|uPqA4|rfm2fksYf&b6s}E!1h_a?zIAU-z2qW3#|5m?3pLPnn?*w zP*%7)BB|o0q58llbTXWiFxu9aWh znxNq4GeWS$@)!=CFiZzB%yFpaE+AZWr!>xO5^5J(#y76`KftU@J z3$)Z43Aguebpeki{(q)qARxoxt~Jfza1Bee2XI5a+=JXDY-H8(emg(2$GPLL!%!^~ zVxS^-^9#jF2g}Sh3^N`Cv=Wj$-wdZg5WG$}m}|fbY`{LXZ99fcsHi&#Vv#YG#n&AOyJ#>9V~BDw;K!VnjUn~2_&lnMa_;`<^LBcdb-^6)`|Z58dJM9X59 zjUlkeQst2=pu~zbR@(IwSqO-T6ftFOeInpit5my6b+vo$jas{Z?6-gH{ce&o^E-29 z&hMN#Gg+FR9*6AE%jfBytP8aGj;Ldl<;(hDlS+}HraM@E3G@#_x?8s22G&w~B6B2@BQ-TbHmb~$0oIU#5zcj8v*?vG==k?g&5{5~O$MIEg_j4jIv_iU}L)row5xoxJFxoH`67 zBOj!CTG~K-p;z^UfHW-lgj5|3rco^2O*NjjVxjHbr7dfNzv#E=G0M!BmNw5w8;KE5 z^-bl8ac9(t5H~!B7rl?Su!(>EYmqC*b>%n+1LA;Q z%pPM*5tUe+mT_Rpwi^G6nrTOlXLKD8IDh=^mx0Xi7=)HB=e2L7uC)# zzMZJ2Rvut$_B7g|e1sf8&n3EvxA;fV#fNh*KKjqsHmv|5w97vgvwsx=j?mjH@>9|y z$=OrNcaQXu_Cz*N1vl{BVr5@kV+2RI{Xy81Yk3cBA>bmE!kb;VvqQuYY81SwQ8|YM zAPtEUySCmDajq6{rKc+M&Nssdh6?bpMWEqIK?lBYqk)XU50xv5@D9GZo9GY}xCGs) zDp?YG&Zu7!+9j$jCbMueFK$uOkf+jQwpOY?i@=py!h-$!HJPCS9OgQDrRrdT8XjX* z1@`zhw*aKK0e?OzidKoD^`dk7fY_3`=Rc?4#r;u>C&pin-|)@j4Qn26xPE!v^~dYJ zy}WL9*}ALd4XdrISAVnRw=d&*z5~iHz74Fy8&hnIab|*}V<9feubo{q7z6Pr7O&J# z$tX+TT2{BUqW%xQj0yw>Sc50Oh6$T!W!a&O19fz;4<9Z%)NuH4X2qe5|Nh!l3y&b= zgpf~14nI1F-;qOm#V{fzTqI8j5@@{G&HpSG@ zhf8&}6|znpQIv^OaU8sXGV~G_;~}ej8g;@Kg6EE+pj>c-7Ot|8a1<7B35y_?>u2F> zT%?Xd15b8Npz)B#?o+x**?^Kx)u~)5E-ZzR+VSBNpn8~`xI7E*dLTTBf| zagz-gP;{Hk1DXB}?erWJwn}S0IgokJc*2wX11wjux5;Xio2m`iLS}L;Vf{|g7y>}7 z*rF%*N=_!hvlX;|HZfY>g0qF2nH=2j9MS$lXt6&Z=C9%jO@g`>S zc4qQKwLfj@<>VGjy~K1GfM9b%7MQ}a?%7OzF_398KTcNPGec^#*ieGr>I$zGPG{x& zBh-pfSV6DAXi##!H3o&7l;SCSVjBz!pkcofl1HrU3E_x$b7iq&3Zg5fRzQ8T8ieE&41Xdc%GQv^` zQtArZXg(D$&O}RhL?W@aj^%{5Is-@~(PAnKWa+u}{9f z8lhwMZ1{|??$wo|a5!dn>PRj42-H|A)owV20hx7}vK!8#I4v;@2NDgysYC;>jw?4Q zBeAP#K%nWR0j?BcRo}uwf|dpQE;udNN#C!RnRKx`==2qVY5-a!=&`Y*wE>G*Fc7cNhn{?2L92f zFMYT(o0O3Zq|69g{S}}AGOA!#av&-)-0LME^QSZ(p$y=}08}ni1KVB%y?-z_1{+}L zqfJ3HOc zYx;F{|7VGZw(P%&u&|Utm+7(TyKB$x-qYxS`e5TLA-(ZV`yhk%zE*m zJp@OXOQ+SzM73kJ5h|p_l2~vd3{nj$DJX}$JqT5u>Q@{wkMOnknUYQC+x^q)dt+hm z%hmV8*lXrOd4Ti_WB*P&qsCCCXl%0bx4SREmdh4OEERbdPGd@q47bShdYVU)wC?Xw zp30-mPkgrrwvOF`v*XFFf(h?&QnWf^ctAaR`fa6lLThJ|I~WX!DCfSgv{Y0fk1>>P{dgmG-Gx+7Q(&55o}&>k6fZU7p+~! zadit~-C+4ir=gfd%M5|J5gd2pUcp{=VCAVF+gzvSt}kG&fB7hX0?OCe6&9wY9c{g3 zH#t&SQ2Vz*dr?%Xh*1bfH{d!cWck(X-+$!Tsg};(fg5*5$B>h|7Zj_PkDVQTxu*ZX zz-7#i`RnMg)`96l^j}9B^>X-di=$euPRUMjIb(`Zt9tUXb*%7BOIMElJ{om~&3O@< zYaibp`VL!*!Uv*4>mRu^|H+M8o|LN7_vW--B{H3$Q4I5vK zg;j7+KJV}Exc_BNYeN0r7wyUq&BM!Lsv7G3k@wSoWzW&Lq2%NymdpIWnP;ByXYRXV zhhA()`2j0D&PqGKUhl0CqG}lr7F05q!B+Z z^!)r`-1X_ylHXh0uAPV4j1Yz<0dxttZudG^D{2e-^W zdiTp=tz+TTX?Ec~o8}qBsEu{(=r~Kn+&h0}wS{gvx-=}ox8z`a+syODUHi8ksBz$) z3w&00@RD2@aJ#Vk=WC(=z~?(m*|9o|6Cpzhlb*HaK3MY4d3Jnr{i7Y7$*tqdjs>tD z-2FS!9Xb4X@pHJ;eH7fb*6HfJOM^p$OTPMP?3Zmnj9j1h=$ogz{d=ywL#?R1bxo7g zr){cSt-9jj^l|O2ABXC{d-8nG!}zYVPj*~?^2l)(YxI;ntbg@V*4pP;AF-`b{raFw z59Er+W7ozDze}FIMBtwDiFn^HQ{Rg@k>DBo?Z>rVn?g@?_^la;Kwob=lR{0JyYthL zzn))w5jMfr#^;WOr^?&DU-#L(9jCv!JkbLo!H3BQM=w5o_VdM`az#$QEnEK$n^Dm* literal 0 HcmV?d00001 diff --git a/resources/sfx/bolter.ogg b/resources/sfx/bolter.ogg new file mode 100644 index 0000000000000000000000000000000000000000..90304bf1359b0a8ac3353e52dc264b13833a61ef GIT binary patch literal 6066 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%0DeYE{8j--P&47DP1Q1}&hIbKG1|%ES_W!ze76>aT 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 Z{@yrb4^PhmzpVEP>L(-)-_#-Ke*mACt4RO= literal 0 HcmV?d00001 diff --git a/resources/sfx/grenade_bounce.ogg b/resources/sfx/grenade_bounce.ogg new file mode 100644 index 0000000000000000000000000000000000000000..08237743aaaef0da69ab31bfede97de8d57fc728 GIT binary patch literal 4958 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_air.ogg b/resources/sfx/hit_air.ogg new file mode 100644 index 0000000000000000000000000000000000000000..c44c2dc7d4ba3ccabdab028be08f293fb5e63d05 GIT binary patch literal 5038 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/hit_electric.ogg b/resources/sfx/hit_electric.ogg new file mode 100644 index 0000000000000000000000000000000000000000..60acc52195c2d7f173cf895df9ab6144a9e83271 GIT binary patch literal 6378 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 literal 0 HcmV?d00001 diff --git a/resources/sfx/zap.ogg b/resources/sfx/hit_electric_heavy.ogg similarity index 95% rename from resources/sfx/zap.ogg rename to resources/sfx/hit_electric_heavy.ogg index 8a2ebf218ea211062405cdd746bab7f1acf2b668..363087fc8dd4c61186579b1c9b112ec9ade27916 100644 GIT binary patch delta 69 zcmaE@^;%2HKRrE|fr$YMj31|N2Qh2Y!%QYh>G6R0j6nWQUf-!3L-hH;0wC3TrVk J+|PH14FGGV6O#Y{ diff --git a/resources/sfx/vacuum.ogg b/resources/sfx/vacuum.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f75fcf85b5f9d4c464ddd323ccb45169c5ad81a6 GIT binary patch literal 5193 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 A4FCWD literal 0 HcmV?d00001 diff --git a/resources/sfx/vacuum_heavy.ogg b/resources/sfx/vacuum_heavy.ogg new file mode 100644 index 0000000000000000000000000000000000000000..61106c26e011dff8f7e13bf6df84b052b8455a93 GIT binary patch literal 7015 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 Z6ZTv1(!ghb0Z3)fVl0CHPa1GO{~uPpg`EHZ literal 0 HcmV?d00001 diff --git a/resources/sfx/vacuum_stop.ogg b/resources/sfx/vacuum_stop.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3b1b5b4c6a2019af99682184e41c4f1e70a13c0b GIT binary patch literal 6687 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 auto data = reinterpret_cast(message.data()); bool isLinked = member.channel && member.channel->isOpen() && member.inputs && member.inputs->isOpen(); + bool isBinary = delivery != DELIVERY_TEXT; try { if (isBinary && isLinked) - (isLossy ? member.inputs : member.channel)->send(data, message.size()); + (delivery == DELIVERY_LOSSY ? member.inputs : member.channel)->send(data, message.size()); else if (isBinary && member.socket->isOpen()) member.socket->send(rtc::binary(data, data + message.size())); else if (member.socket->isOpen()) @@ -210,10 +226,10 @@ static void error_send(int id, const char* message) send(id, "{\"type\":\"error\",\"message\":" + json_quote(message) + "}"); } -static void lobby_send(const std::string& code, const std::string& message, bool isBinary = false) +static void lobby_send(const std::string& code, const std::string& message, Delivery delivery = DELIVERY_TEXT) { for (int id : lobbies[code].members) - send(id, message, isBinary); + send(id, message, delivery); } // A name as the lobby shows it: printable characters only, cut to length, or "Player" when nothing's left. @@ -257,12 +273,12 @@ static void event_add(int serial, RoomEvent event) packet.put(PACKET_EVENT).put(serial).put((int)room.events.size() - 1).put(event); for (auto& [id, member] : members) if (member.room == serial) - send(id, packet.bytes, true); + send(id, packet.bytes, DELIVERY_RELIABLE); } // Puts a member in a room as its next tick starts: their fighter joins (from their profile, or their seat in a game // already set up for them), and they get the room as it is then, with every event it's had. -static void room_enter(int id, int serial, bool isJoining) +static void room_enter(int id, int serial, RoomEntry entry) { Member& member = members[id]; Room& room = rooms[serial]; @@ -272,10 +288,10 @@ static void room_enter(int id, int serial, bool isJoining) PacketWriter packet; packet.put(PACKET_STATE).put(serial).put(room.tick); packet.bytes += state_save(room.sim); - send(id, packet.bytes, true); + send(id, packet.bytes, DELIVERY_RELIABLE); for (size_t i = 0; i < room.events.size(); i++) - send(id, PacketWriter().put(PACKET_EVENT).put(serial).put((int)i).put(room.events[i]).bytes, true); - if (isJoining) + send(id, PacketWriter().put(PACKET_EVENT).put(serial).put((int)i).put(room.events[i]).bytes, DELIVERY_RELIABLE); + if (entry == ENTRY_JOIN) event_add(serial, {.type = ROOM_JOIN, .member = id, .profile = member.profile}); } @@ -322,7 +338,7 @@ static void lobby_enter(int id, const std::string& code) // 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, with its rooms. -static void lobby_exit(int id, const std::string& kick = "", bool isKicked = false) +static void lobby_exit(int id, LeaveReason leave = LEAVE_QUIT, const std::string& kick = "") { std::string code = members[id].code; if (code.empty()) @@ -339,7 +355,7 @@ static void lobby_exit(int id, const std::string& kick = "", bool isKicked = fal std::printf("Lobby %s closed (%zu open)\n", code.c_str(), lobbies.size()); return; } - std::string reason = isKicked ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : ""; + std::string reason = leave == LEAVE_KICKED ? ",\"kicked\":true,\"reason\":" + json_quote(kick) : ""; lobby_send(code, "{\"type\":\"member_leave\",\"id\":" + std::to_string(id) + ",\"host\":" + std::to_string(lobby.members.front()) + reason + "}"); } @@ -409,7 +425,7 @@ static void text_handle(int id, const std::string& text) 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); + return lobby_exit((int)target, LEAVE_KICKED, reason); } error_send(id, "Unknown message"); } @@ -428,7 +444,7 @@ static void text_relay(int id, PacketType type, PacketReader& reader, size_t max PacketWriter packet; packet.put(type).put(id).put((Uint16)text.size()); packet.bytes += text; - lobby_send(members[id].code, packet.bytes, true); + lobby_send(members[id].code, packet.bytes, DELIVERY_RELIABLE); } // The game messages, in binary (see protocol.h), from a member in a lobby. @@ -450,7 +466,7 @@ static void packet_handle(int id, const std::string& bytes) member.profile.color = std::clamp(member.profile.color, -1, COLOR_COUNT - 1); std::memcpy(member.profile.name, member.name.c_str(), member.name.size() + 1); if (member.room == 0) - room_enter(id, lobbies[member.code].rooms.front(), true); + room_enter(id, lobbies[member.code].rooms.front(), ENTRY_JOIN); return; } if (type == PACKET_INPUTS) @@ -467,6 +483,9 @@ static void packet_handle(int id, const std::string& bytes) Room& room = rooms[serial]; member.ack = std::max(member.ack, std::min(ack, room.tick - 1)); member.lead = first + count - 1 - room.tick; + // A spectator's controls play nothing, so aren't kept. + if (room.sim.match.fighter_find(room.sim, id) < 0) + return; for (int i = 0; i < count; i++) { Controls controls; @@ -493,13 +512,38 @@ static void packet_handle(int id, const std::string& bytes) { Uint8 isTyping = 0; if (reader.get(isTyping)) - lobby_send(member.code, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes, true); + lobby_send(member.code, PacketWriter().put(PACKET_TYPING).put(id).put(isTyping).bytes, DELIVERY_RELIABLE); + } + if (type == PACKET_WATCH) + { + // From the sandbox, spectating one of the lobby's games. Spectating, back to the sandbox. Someone playing in a + // game leaves it by its own means. + int serial = 0; + std::vector& lobbyRooms = lobbies[member.code].rooms; + int sandbox = lobbyRooms.front(); + bool isInSandbox = member.room == sandbox; + auto room = rooms.find(member.room); + bool isPlaying = room != rooms.end() && room->second.sim.match.fighter_find(room->second.sim, id) >= 0; + if (!reader.get(serial)) + return; + bool isGame = serial != sandbox && std::find(lobbyRooms.begin(), lobbyRooms.end(), serial) != lobbyRooms.end(); + if (isGame && isInSandbox) + { + room_exit(id); + room_enter(id, serial, ENTRY_SEATED); + } + else if (serial == 0 && !isInSandbox && !isPlaying && member.room != 0) + { + room_exit(id); + room_enter(id, sandbox, ENTRY_JOIN); + } + return; } if (type == PACKET_PING) { Uint16 ping = 0; if (reader.get(ping)) - lobby_send(member.code, PacketWriter().put(PACKET_PING).put(id).put(ping).bytes, true); + lobby_send(member.code, PacketWriter().put(PACKET_PING).put(id).put(ping).bytes, DELIVERY_RELIABLE); } } @@ -565,7 +609,7 @@ static void game_open(int sandboxSerial, int queuer) for (int k = 0; k < count; k++) { room_exit(players[k]); - room_enter(players[k], serial, false); + room_enter(players[k], serial, ENTRY_SEATED); } } @@ -588,7 +632,7 @@ static void game_follow(int serial) if (match.isGone[k]) { room_exit(owner); - room_enter(owner, sandbox, true); + room_enter(owner, sandbox, ENTRY_JOIN); continue; } seats[humans++] = k; @@ -606,13 +650,43 @@ static void game_follow(int serial) { int owner = match.owners[seats[k]]; room_exit(owner); - room_enter(owner, next, false); + room_enter(owner, next, ENTRY_SEATED); } } + // Anyone still here was spectating, and goes back to the sandbox. + for (auto& [id, member] : members) + { + if (member.room != serial) + continue; + room_exit(id); + room_enter(id, sandbox, ENTRY_JOIN); + } std::erase(lobbies[game.code].rooms, serial); rooms.erase(serial); } +// Tells a lobby's members who's in each of its rooms (the sandbox, then its games): each room's mode and the players +// still in it with their colours, for the lobby page's lists. +static void rooms_send(const std::string& code) +{ + std::vector& lobbyRooms = lobbies[code].rooms; + PacketWriter packet; + packet.put(PACKET_ROOMS).put((Uint8)lobbyRooms.size()); + for (int serial : lobbyRooms) + { + Match& match = rooms[serial].sim.match; + int fighters = rooms[serial].sim.fighterCount; + Uint8 count = 0; + for (int k = 0; k < fighters; k++) + count += match.owners[k] >= 0 && !match.isGone[k]; + packet.put(serial).put((Uint8)match.rules.mode).put(count); + for (int k = 0; k < fighters; k++) + if (match.owners[k] >= 0 && !match.isGone[k]) + packet.put(match.owners[k]).put((Uint8)match.colors[k]); + } + lobby_send(code, packet.bytes, DELIVERY_RELIABLE); +} + // Sends everyone in a room the frames they haven't confirmed (as many as fit a packet), and every so often the state's // checksum. static void frames_send(int serial) @@ -642,14 +716,14 @@ static void frames_send(int serial) for (int i = 0; i < frame.count; i++) packet.put(frame.controls[i]); } - send(id, packet.bytes, true, true); + send(id, packet.bytes, DELIVERY_LOSSY); } if (room.tick % CHECKSUM_INTERVAL == 0) for (auto& [id, member] : members) if (member.room == serial) send(id, PacketWriter().put(PACKET_CHECKSUM).put(serial).put(room.tick).put(room.sim.checksum_get()).bytes, - true); + DELIVERY_RELIABLE); } // One tick of every room, in every lobby: the sandbox, then its games, then the frames out. @@ -669,6 +743,8 @@ static void rooms_step() int queuer = room.sim.match.queue_startable_get(room.sim); if (queuer >= 0) game_open(serial, queuer); + if (room.tick % CHECKSUM_INTERVAL == 0) + rooms_send(room.code); } else game_follow(serial); diff --git a/src/anm2.cpp b/src/anm2.cpp index f390ec8..0e00b1c 100644 --- a/src/anm2.cpp +++ b/src/anm2.cpp @@ -25,6 +25,7 @@ struct Anm2Source { std::vector sheets; std::vector layerSheets; + int nullCount = 0; }; static float number_get(object source, const char* key, float fallback) @@ -78,6 +79,7 @@ static Anm2Frame frame_parse(object source, Anm2Sheet* sheet) { frame.region = sheet->regions[region].region; frame.pivot = sheet->regions[region].pivot; + frame.top = sheet->regions[region].top; } float tint[] = {CHANNEL, CHANNEL, CHANNEL, CHANNEL}; @@ -144,6 +146,17 @@ static void animations_parse(object list, Anm2Source& source, std::vector= 0 && sheet < (int)source.sheets.size(); animation.layers.push_back( track_parse(trackFields, "frames", isSheet ? &source.sheets[sheet] : nullptr)); + animation.layerIds.push_back(layer); + } + if (!fields["nulls"].get(tracks)) + for (element track : tracks) + { + object trackFields; + int64_t null; + if (track.get(trackFields) || trackFields["null"].get(null) || null < 0 || null >= source.nullCount) + continue; + animation.nulls.resize(source.nullCount); + animation.nulls[null] = track_parse(trackFields, "frames", nullptr); } animations.push_back(animation); } @@ -185,16 +198,31 @@ void Anm2::load(Renderer& renderer, const char* name) continue; Anm2Sheet sheet = {renderer.image_load(GFX_FOLDER + std::string(image))}; for (Anm2Frame& region : track_parse(fields, "regions", &sheet)) + { + region.top = renderer.top_get(region.region); 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)); + std::string_view layer; + bool isRead = !entry.get(fields); + source.layerSheets.push_back(isRead ? (int)number_get(fields, "sheet", 0) : -1); + layerNames.emplace_back(isRead && !fields["name"].get(layer) ? layer : ""); } + if (!document["nulls"].get(list)) + for (element entry : list) + { + object fields; + std::string_view null; + nullNames.emplace_back(entry.get(fields) || fields["name"].get(null) ? "" : null); + } + source.nullCount = nullNames.size(); + object animationList; if (!document["animations"].get(animationList)) animations_parse(animationList, source, animations); @@ -265,28 +293,56 @@ static Color color_multiply(Color a, Color b) } // Draws an animation's layers at a frame (looping or holding its last), through the root and then the placement. +// A frame number brought into an animation: wrapped round a looping one, held at the ends of one that isn't. +static float frame_wrap(Anm2Animation& animation, float frame) +{ + float length = animation.length; + return animation.isLooping ? frame - std::floor(frame / length) * length : std::clamp(frame, 0.0f, length - 1); +} + +// Where an animation's root keyframe puts its layers, placed like a sprite. +static Affine base_affine(Anm2Frame root, Draw placement) +{ + root.pivot = {}; + return affine_get(placement.position, placement.angle, placement.scale, placement.origin) * frame_affine(root); +} + 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); + for (int track = 0; animation && track < (int)animation->layers.size(); track++) + track_draw(renderer, *animation, track, frame, placement); +} - Anm2Frame root = frame_get(animation->root, frame); - if (!root.isVisible) +// One of an animation's layer tracks at a frame, placed like a sprite. +void Anm2::track_draw(Renderer& renderer, Anm2Animation& animation, int track, float frame, Draw placement) +{ + frame = frame_wrap(animation, frame); + Anm2Frame root = frame_get(animation.root, frame); + Anm2Frame layer = frame_get(animation.layers[track], frame); + if (!root.isVisible || animation.layers[track].empty() || !layer.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); - } + Affine affine = base_affine(root, placement) * frame_affine(layer); + renderer.quad_draw(layer.region, affine, color_multiply(color_multiply(placement.color, root.tint), layer.tint), + placement.flash); +} + +// Where one of an animation's nulls is at a frame (by its name), placed as draw places the animation, for drawing +// something on it: attached comes back as placement moved onto the null and flipped and scaled with it. False if the +// animation doesn't key that null. +bool Anm2::null_get(const char* name, const char* null, float frame, Draw placement, Draw& attached) +{ + Anm2Animation* animation = animation_get(name); + int index = std::find(nullNames.begin(), nullNames.end(), null) - nullNames.begin(); + if (!animation || index >= (int)animation->nulls.size() || animation->nulls[index].empty()) + return false; + frame = frame_wrap(*animation, frame); + Anm2Frame point = frame_get(animation->nulls[index], frame); + attached = placement; + attached.position = base_affine(frame_get(animation->root, frame), placement) * point.position; + attached.scale = {placement.scale.x * point.scale.x, placement.scale.y * point.scale.y}; + attached.origin = {}; + return true; } void Pose::draw(Renderer& renderer) @@ -325,3 +381,27 @@ Pose Animator::pose_get(Draw placement) { return {.art = art, .animation = animation, .placement = placement, .frame = frame}; } + +// The greatest height a null reaches above its animation's origin, over every frame of every animation keying it. +float Anm2::null_height_get(const char* null) +{ + int index = std::find(nullNames.begin(), nullNames.end(), null) - nullNames.begin(); + float height = 0; + for (Anm2Animation& animation : animations) + if (index < (int)animation.nulls.size()) + for (Anm2Frame& frame : animation.nulls[index]) + height = std::max(height, -frame.position.y); + return height; +} + +// How far an animation's art reaches above its origin at most, over its layers' keyframes (from each pivot up to where +// its art starts). +float Anm2::rise_get(const char* name) +{ + Anm2Animation* animation = animation_get(name); + float rise = 0; + for (int track = 0; animation && track < (int)animation->layers.size(); track++) + for (Anm2Frame& frame : animation->layers[track]) + rise = std::max(rise, (frame.pivot.y - frame.top) * std::abs(frame.scale.y)); + return rise; +} diff --git a/src/anm2.h b/src/anm2.h index 8fa8393..36d65ae 100644 --- a/src/anm2.h +++ b/src/anm2.h @@ -13,7 +13,7 @@ enum Anm2Easing 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 +// One keyframe of a root, layer or null track. Region is in the atlas and the pivot inside it. Scale is a fraction and // rotation is in radians. struct Anm2Frame { @@ -23,17 +23,23 @@ struct Anm2Frame Vec2 position; Vec2 scale = {1, 1}; float rotation = 0; + // How far down its region the art starts. + float top = 0; int delay = 1; int easing = EASING_NONE; bool isVisible = true; }; -// Layers hold one track of keyframes each, drawn in order. +// Layers hold one track of keyframes each, drawn in order, with the document layer each keys (layerIds). Nulls hold a +// track each too, by the document's null they follow (empty for one the animation doesn't key): points that move with +// it, drawing nothing. struct Anm2Animation { std::string name; std::vector root; std::vector> layers; + std::vector> nulls; + std::vector layerIds; int length = 1; bool isLooping = true; }; @@ -45,11 +51,17 @@ class Anm2 { public: std::vector animations; + std::vector layerNames; + std::vector nullNames; 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); + void track_draw(Renderer& renderer, Anm2Animation& animation, int track, float frame, Draw placement); + bool null_get(const char* animation, const char* null, float frame, Draw placement, Draw& attached); + float null_height_get(const char* null); + float rise_get(const char* animation); }; // An animation frame placed like a sprite, ready to draw. Afterimages keep these. diff --git a/src/audio.cpp b/src/audio.cpp index e682d70..bac947f 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -5,10 +5,15 @@ #include constexpr float PITCH_VARIANCE = 0.06f; + +static void SDLCALL loop_refill(void* userdata, SDL_AudioStream* stream, int additional, int total); constexpr Uint64 SOUND_GAP = 40 * SDL_NS_PER_MS; constexpr const char* SOUND_NAMES[SOUND_COUNT] = { "beam", + "beep", "blast", + "bolter", + "bolter_heavy", "boomerang", "boomerang_heavy", "bounce", @@ -23,6 +28,7 @@ constexpr const char* SOUND_NAMES[SOUND_COUNT] = { "death", "detonate", "detonate_empty", + "donk", "duck", "echo", "equip", @@ -36,10 +42,16 @@ constexpr const char* SOUND_NAMES[SOUND_COUNT] = { "flame", "grapple", "grapple_heavy", + "grenade", + "grenade_bounce", + "hit_air", + "hit_air_heavy", "hit_blunt", "hit_blunt_heavy", "hit_bullet", "hit_bullet_heavy", + "hit_electric", + "hit_electric_heavy", "hit_energy", "hit_energy_heavy", "hit_explosive", @@ -92,10 +104,13 @@ constexpr const char* SOUND_NAMES[SOUND_COUNT] = { "uppercut", "uzi", "uzi_heavy", + "vacuum", + "vacuum_heavy", + "vacuum_start", + "vacuum_stop", "whip", "whip_heavy", "wisp", - "zap", }; void Audio::init() @@ -119,13 +134,40 @@ void Audio::init() SDL_AudioSpec deviceSpec = {}; SDL_GetAudioDeviceFormat(device, &deviceSpec, nullptr); - for (SDL_AudioStream*& voice : voices) + for (int i = 0; i < VOICE_COUNT; i++) { - voice = SDL_CreateAudioStream(&deviceSpec, &deviceSpec); - SDL_BindAudioStream(device, voice); + voices[i] = SDL_CreateAudioStream(&deviceSpec, &deviceSpec); + SDL_BindAudioStream(device, voices[i]); + loops[i].audio = this; + SDL_SetAudioStreamGetCallback(voices[i], loop_refill, &loops[i]); } } +// Called on the audio thread whenever a voice wants more: a looping voice gets its sound again. +static void SDLCALL loop_refill(void* userdata, SDL_AudioStream* stream, int additional, int total) +{ + Loop& loop = *(Loop*)userdata; + if (additional > 0 && loop.sound != SOUND_NONE) + loop.audio->loop_feed(stream, loop); +} + +void Audio::loop_feed(SDL_AudioStream* stream, Loop& loop) +{ + Sound& sound = sounds[loop.sound]; + SDL_PutAudioStreamData(stream, sound.samples, sound.frames * sound.channels * sizeof(short)); +} + +// Plays a sound over and over (at its own pitch) until the voice it returns is stopped, or -1 if it can't. +int Audio::loop_play(SoundId id) +{ + int voice = play(id); + if (voice < 0) + return -1; + SDL_SetAudioStreamFrequencyRatio(voices[voice], 1); + loops[voice].sound = id; + return 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) { @@ -137,10 +179,13 @@ int Audio::play(SoundId id) return -1; sound.played = now; + // A free voice, or else the next in turn that isn't looping. int voice = voiceNext; + for (int i = 0; i < VOICE_COUNT && loops[voice].sound != SOUND_NONE; i++) + voice = (voice + 1) % VOICE_COUNT; for (int i = 0; i < VOICE_COUNT; i++) { - if (SDL_GetAudioStreamQueued(voices[i]) == 0) + if (SDL_GetAudioStreamQueued(voices[i]) == 0 && loops[i].sound == SOUND_NONE) { voice = i; break; @@ -149,6 +194,7 @@ int Audio::play(SoundId id) voiceNext = (voice + 1) % VOICE_COUNT; SDL_AudioStream* stream = voices[voice]; + loops[voice].sound = SOUND_NONE; SDL_AudioSpec spec = {SDL_AUDIO_S16, sound.channels, sound.rate}; SDL_ClearAudioStream(stream); SDL_SetAudioStreamFormat(stream, &spec, nullptr); @@ -166,6 +212,8 @@ void Audio::gain_set(float gain) void Audio::stop(int voice) { - if (voice >= 0) - SDL_ClearAudioStream(voices[voice]); + if (voice < 0) + return; + loops[voice].sound = SOUND_NONE; + SDL_ClearAudioStream(voices[voice]); } diff --git a/src/audio.h b/src/audio.h index 0ed3142..1db2b86 100644 --- a/src/audio.h +++ b/src/audio.h @@ -1,6 +1,9 @@ #pragma once +#include + struct SDL_AudioStream; +class Audio; constexpr int VOICE_COUNT = 24; @@ -8,7 +11,10 @@ enum SoundId { SOUND_NONE = -1, SOUND_BEAM, + SOUND_BEEP, SOUND_BLAST, + SOUND_BOLTER, + SOUND_BOLTER_HEAVY, SOUND_BOOMERANG, SOUND_BOOMERANG_HEAVY, SOUND_BOUNCE, @@ -23,6 +29,7 @@ enum SoundId SOUND_DEATH, SOUND_DETONATE, SOUND_DETONATE_EMPTY, + SOUND_DONK, SOUND_DUCK, SOUND_ECHO, SOUND_EQUIP, @@ -36,10 +43,16 @@ enum SoundId SOUND_FLAME, SOUND_GRAPPLE, SOUND_GRAPPLE_HEAVY, + SOUND_GRENADE, + SOUND_GRENADE_BOUNCE, + SOUND_HIT_AIR, + SOUND_HIT_AIR_HEAVY, SOUND_HIT_BLUNT, SOUND_HIT_BLUNT_HEAVY, SOUND_HIT_BULLET, SOUND_HIT_BULLET_HEAVY, + SOUND_HIT_ELECTRIC, + SOUND_HIT_ELECTRIC_HEAVY, SOUND_HIT_ENERGY, SOUND_HIT_ENERGY_HEAVY, SOUND_HIT_EXPLOSIVE, @@ -92,10 +105,13 @@ enum SoundId SOUND_UPPERCUT, SOUND_UZI, SOUND_UZI_HEAVY, + SOUND_VACUUM, + SOUND_VACUUM_HEAVY, + SOUND_VACUUM_START, + SOUND_VACUUM_STOP, SOUND_WHIP, SOUND_WHIP_HEAVY, SOUND_WISP, - SOUND_ZAP, SOUND_COUNT }; @@ -109,6 +125,14 @@ struct Sound int rate = 0; }; +// A voice playing a sound over and over (see Audio::loop_play): the sound, or SOUND_NONE when it isn't looping. The +// audio thread reads it to top the voice up, so it's atomic. +struct Loop +{ + Audio* audio = nullptr; + std::atomic sound = SOUND_NONE; +}; + // 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 @@ -116,12 +140,15 @@ class Audio public: void init(); int play(SoundId id); + int loop_play(SoundId id); void stop(int voice); + void loop_feed(SDL_AudioStream* stream, Loop& loop); void gain_set(float gain); private: Sound sounds[SOUND_COUNT]; SDL_AudioStream* voices[VOICE_COUNT] = {}; + Loop loops[VOICE_COUNT]; unsigned int device = 0; int voiceNext = 0; }; diff --git a/src/balance.cpp b/src/balance.cpp index 37b8d48..a6a23d5 100644 --- a/src/balance.cpp +++ b/src/balance.cpp @@ -42,8 +42,9 @@ static void tallies_print(const char* title, Tally* tallies, int count) // 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) +static void state_test(Game& game, int match) { + bool isFirst = match == 0; std::string bytes = state_save(game.sim); const char* imported = SDL_getenv("RPG_STATE_IMPORT_HEX"); if (isFirst && SDL_getenv("RPG_STATE_PRINT")) @@ -115,7 +116,7 @@ void Balance::matches_run(Game& game, int count, MatchMode mode) game_tick(game); hash = (hash ^ game.sim.checksum_get()) * HASH_PRIME; if (tick == STATE_TEST_TICK && isStateTest) - state_test(game, match == 0); + state_test(game, match); } ticks += tick; if (count >= PROGRESS_STEPS && (match + 1) % (count / PROGRESS_STEPS) == 0) diff --git a/src/bot.cpp b/src/bot.cpp index 631c953..7881c41 100644 --- a/src/bot.cpp +++ b/src/bot.cpp @@ -12,6 +12,11 @@ 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; +// Timing a grenade: how long after its clonk it should go off, and the least and most charge worth holding for it (a +// full one blasts point-blank instead). +constexpr float DONK_LEAD = 0.1f; +constexpr float CHARGE_GOAL_MIN = 0.1f; +constexpr float CHARGE_GOAL_MAX = 0.95f; constexpr float DASH_SHORTFALL = 3 * TILE_SIZE; constexpr float DASH_CHASE = 120; constexpr float DASH_DODGE = 60; @@ -70,6 +75,11 @@ static int grapple_slot_get(Fighter& fighter) return -1; } +static Mobility mobility_get(Fighter& fighter) +{ + return grapple_slot_get(fighter) >= 0 ? MOBILITY_GRAPPLE : MOBILITY_PLAIN; +} + static bool is_sight_clear(Sim& sim, Vec2 from, Vec2 to) { Vec2 delta = to - from; @@ -91,15 +101,24 @@ static float jump_reach_get(float rise) // 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) +static BotHint hint_get(Fighter& fighter, int weaponSlot, AttackVariant variant) { 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; + float range = reach_get(WEAPON_DEFS[weapon].attack_get(variant).path, level.stats_get(variant)); + + // A grenade has no range of its own, so its hint is its reach, and a point-blank blast reaches as far as it + // spreads. + if (WEAPON_DEFS[weapon].normal.kind == ATTACK_GRENADE) + { + if (variant == VARIANT_CHARGED) + hint = {.rangeMax = level.charged.area, .charge = hint.charge}; + return hint; + } 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) + if (variant == VARIANT_CHARGED && WEAPON_DEFS[weapon].charged.kind == ATTACK_WHIP) { hint.rangeMin = range - WHIP_SWEET_SPOT; hint.rangeMax = range + WHIP_SWEET_SPOT; @@ -107,6 +126,21 @@ static BotHint hint_get(Fighter& fighter, int weaponSlot, bool isCharged) return hint; } +// A grenade's charge is planned from the target's distance: just enough that its fuse runs out as it reaches the target +// (clonk, then blast, for a double donk), or full to blast a target already in its face. +static void charge_plan(Fighter& fighter, BotState& state, Vec2 seen) +{ + const WeaponDef& def = WEAPON_DEFS[fighter.weapon_get()]; + const LevelDef& level = fighter.level_get(fighter.slot); + Vec2 hand = fighter.hand_get(); + float distance = vec2_length(seen - hand); + float flight = distance / (level.normal.speed * fighter.stat_get(STAT_PROJECTILE_SPEED)); + float timed = 1 - (flight + DONK_LEAD) / level.normal.lifetime; + state.chargeGoal = timed > CHARGE_GOAL_MIN ? std::min(timed, CHARGE_GOAL_MAX) : 0; + if (distance < level.charged.area) + state.chargeGoal = 1; +} + // Where the target was ticksAgo ticks back, as far back as the bot has seen it. static Vec2 seen_get(BotState& state, int ticksAgo) { @@ -367,7 +401,7 @@ int Bot::surface_find(Fighter& fighter) // 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) +bool Bot::is_path_found(BotState& state, int from, int to, Mobility mobility) { std::vector costs(surfaces.size(), INFINITY); std::vector via(surfaces.size(), -1); @@ -387,7 +421,7 @@ bool Bot::is_path_found(BotState& state, int from, int to, bool isGrappler) { 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)) + if (cost < costs[link.to] && (mobility == MOBILITY_GRAPPLE || link.kind != LINK_GRAPPLE)) { costs[link.to] = cost; via[link.to] = i; @@ -497,7 +531,7 @@ void Bot::think(Sim& sim, Fighter& fighter, BotState& state) int weapon = fighter.weaponLoadout[slot]; if (weapon < 0) continue; - BotHint hint = hint_get(fighter, slot, false); + BotHint hint = hint_get(fighter, slot, VARIANT_NORMAL); float miss = std::max({0.0f, hint.rangeMin - distance, distance - hint.rangeMax}); if (miss < bestMiss) { @@ -508,17 +542,22 @@ void Bot::think(Sim& sim, Fighter& fighter, BotState& state) int weapon = fighter.weapon_get(); ChargePolicy policy = weapon >= 0 ? WEAPON_DEFS[weapon].bot.charge : CHARGE_NEVER; - if (fighter.charge <= 0) - state.isChargePlanned = + if (policy == CHARGE_PLANNED) + charge_plan(fighter, state, seen); + else if (fighter.charge <= 0) + { + bool isPlanned = 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{}; + state.chargeGoal = isPlanned ? 1 : 0; + } + BotHint hint = weapon >= 0 ? hint_get(fighter, fighter.slot, variant_get(state.chargeGoal)) : 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; + float danger = hint_get(target, target.slot, VARIANT_CHARGED).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; @@ -551,7 +590,7 @@ void Bot::think(Sim& sim, Fighter& fighter, BotState& state) 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)) + if (current >= 0 && !is_path_found(state, current, goalSurface, mobility_get(fighter))) state.pathLength = 0; } @@ -565,7 +604,7 @@ void Bot::race_think(Fighter& fighter, BotState& state) 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)) + if (current >= 0 && !is_path_found(state, current, finish, mobility_get(fighter))) state.pathLength = 0; } @@ -730,7 +769,7 @@ void Bot::trinket_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& c return; } - int climb = fighter.trinket_get(TRINKET_CLIMBING, 0); + int climb = fighter.stat_get(STAT_CLIMB); int wall = climb > 0 ? fighter.wall_get(sim) : 0; bool isWantingUp = state.landingY < feet - CLIMB_RISE || targetCenter.y < center.y - 2 * TILE_SIZE; if (wall != 0 && isWantingUp) @@ -758,7 +797,7 @@ void Bot::trinket_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& c 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{}; + BotHint hint = weapon >= 0 ? hint_get(fighter, fighter.slot, variant_get(state.chargeGoal)) : BotHint{}; bool isChasing = weapon >= 0 && hint.rangeMax <= DASH_SHORT_RANGE && distance > hint.rangeMax && distance < hint.rangeMax + DASH_CHASE; bool isDodging = state.isAlert && is_threatened(sim, fighter); @@ -769,32 +808,21 @@ void Bot::trinket_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& c 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) +// projectile flight: straight at it (instant attacks too, like a charged grapple), straight at its feet (for a bolt to +// land and crawl through), or ahead of it to where it will be when the shot arrives (raised for shots that drop, +// lowered to the feet for splash). +static Vec2 aim_point_get(Fighter& fighter, int weaponSlot, AttackVariant variant, 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) + const AttackDef& def = WEAPON_DEFS[weapon].attack_get(variant); + float speed = (level.stats_get(variant).speed) * fighter.stat_get(STAT_PROJECTILE_SPEED); + if (style == AIM_DIRECT || speed <= 0 || def.isInstant) return position; + if (style == AIM_FEET) + return position + Vec2{0, BOX_HEIGHT / 2}; // Splash shots go for the ground under the target, so only its sideways movement matters. if (style == AIM_GROUND) @@ -804,11 +832,10 @@ static Vec2 aim_point_get(Fighter& fighter, int weaponSlot, bool isCharged, Vec2 float time = 0; for (int i = 0; i < 2; i++) { - time = flight_time_get(kind, speed, vec2_length(point - hand)); + time = flight_time_get(def, speed, vec2_length(point - hand)); point = position + velocity * time; } - if (style == AIM_LOB) - point.y -= ARROW_GRAVITY * time * time / 2; + point.y -= def.gravity * time * time / 2; if (style == AIM_GROUND) point.y += BOX_HEIGHT / 2; return point; @@ -831,7 +858,8 @@ void Bot::attack_aim(Sim& sim, Fighter& fighter, BotState& state, Controls& cont controls.slot = state.slot; int weapon = fighter.weapon_get(); - Vec2 point = weapon >= 0 ? aim_point_get(fighter, fighter.slot, state.isChargePlanned, seen, velocity) : seen; + AttackVariant variant = variant_get(state.chargeGoal); + Vec2 point = weapon >= 0 ? aim_point_get(fighter, fighter.slot, variant, 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))); @@ -847,13 +875,14 @@ void Bot::attack_aim(Sim& sim, Fighter& fighter, BotState& state, Controls& cont return; } - BotHint hint = hint_get(fighter, fighter.slot, state.isChargePlanned); + BotHint hint = hint_get(fighter, fighter.slot, variant); 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(sim, hand, seen); - state.chargeHeld = fighter.charge >= 1 ? state.chargeHeld + TICK : 0; + bool isCharged = state.chargeGoal > 0 && fighter.charge >= state.chargeGoal; + state.chargeHeld = isCharged ? 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. @@ -873,18 +902,17 @@ void Bot::attack_aim(Sim& sim, Fighter& fighter, BotState& state, Controls& cont control_tap(fighter, controls, CONTROL_ATTACK); return; } - if (is_mine_armed(attack) && - vec2_length(attack.position - target.center_get()) < attack.stats.area * attack.scale) + if (is_armed(attack) && vec2_length(attack.position - target.center_get()) < attack.stats.area * attack.scale) control_set(controls, CONTROL_ATTACK, true); } - if (state.isChargePlanned) + if (state.chargeGoal > 0) { bool isReady = (isInBand || state.chargeHeld > CHARGE_HOLD_MAX) && isAimed; - bool isHolding = fighter.charge < 1 ? distance < hint.rangeMax * CHARGE_START_REACH : !isReady; + bool isHolding = isCharged ? !isReady : distance < hint.rangeMax * CHARGE_START_REACH; control_set(controls, CONTROL_CHARGE, isHolding); - if (fighter.charge >= 1 && isReady) - state.isChargePlanned = false; + if (isCharged && isReady) + state.chargeGoal = 0; return; } // A Grimoire gathers wisps for a while, then lets go for a tick to send them. diff --git a/src/bot.h b/src/bot.h index 0371777..a9389c4 100644 --- a/src/bot.h +++ b/src/bot.h @@ -75,6 +75,13 @@ struct Link int to = 0; }; +// What a bot can get around with: running and jumping, or swinging on a grapple too. +enum Mobility +{ + MOBILITY_PLAIN, + MOBILITY_GRAPPLE +}; + // 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 @@ -88,6 +95,8 @@ struct BotState float aimAngle = 0; float aimError = 0; float attackHeld = 0; + // How far it means to charge before letting go: 0 not at all, 1 fully, and between for a timed grenade. + float chargeGoal = 0; float chargeHeld = 0; float dropY = 0; float grappleTimer = 0; @@ -105,7 +114,6 @@ struct BotState int target = -1; bool isAirborne = false; bool isAlert = false; - bool isChargePlanned = false; bool isDashed = false; bool isDropping = false; bool isGrappling = false; @@ -138,7 +146,7 @@ private: 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); + bool is_path_found(BotState& state, int from, int to, Mobility mobility); void think(Sim& sim, Fighter& fighter, BotState& state); void race_think(Fighter& fighter, BotState& state); void move_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); diff --git a/src/chat.cpp b/src/chat.cpp index 38d0255..41109f2 100644 --- a/src/chat.cpp +++ b/src/chat.cpp @@ -83,10 +83,30 @@ void Chat::clear() void Chat::players_clear() { bubbles.clear(); + pvps.clear(); typing.clear(); recent.clear(); } +// In a lobby's sandbox, tells everyone whenever a player turns PvP on or off, in their colour. Someone just arrived +// is only noted. +void Chat::pvps_announce(Game& game) +{ + if (!game.session.isJoined || game.sim.match.state != MATCH_PRACTICE) + return; + for (int i = 0; i < game.sim.fighterCount; i++) + { + int owner = game.sim.match.owners[i]; + bool isPvp = game.sim.fighters[i].isPvp; + auto known = pvps.find(owner); + bool isChanged = known != pvps.end() && known->second != isPvp; + pvps[owner] = isPvp; + if (isChanged) + system(game.session.name_get(game, owner) + (isPvp ? " turned PvP on." : " turned PvP off."), + game.color_get(i, TINT_PLAIN)); + } +} + // 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) @@ -96,9 +116,9 @@ int Chat::member_get(Game& game, int fighter) return fighter == game.local ? game.net.selfId : NO_MEMBER; } -void Chat::system(const std::string& text) +void Chat::system(const std::string& text, Color color) { - lines.push_back({.text = text, .color = WHITE, .time = seconds_get()}); + lines.push_back({.text = text, .color = color, .time = seconds_get()}); if ((int)lines.size() > CHAT_LINES_MAX) lines.erase(lines.begin()); } @@ -118,7 +138,7 @@ void Chat::say(Game& game, int member, const std::string& text) 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; + Color color = fighter >= 0 ? game.color_get(fighter, TINT_PLAIN) : 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) @@ -135,6 +155,7 @@ void Chat::update(Game& game) for (std::string& notice : game.net.notices) system(notice); game.net.notices.clear(); + pvps_announce(game); Input& input = game.input; if (!isOpen && !game.menu.isOpen && input.is_key_control_pressed(CONTROL_CHAT)) open(game); @@ -219,7 +240,7 @@ static std::vector arguments_split(const std::string& text) return words; } -// /name and /color change this player's (as Customize does), /kick "" "" (the host +// /name and /color change this player's (as Profile 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) { @@ -248,7 +269,9 @@ void Chat::command(Game& game, const std::string& text) // Sent through this screen's controls like the menu's toggle, so every machine sees it on the same tick. bool isPvp = !game.local_get().isPvp; game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_PVP, .value = isPvp})); - return system(isPvp ? "PvP is now on." : "PvP is now off."); + if (!game.session.isActive) + system(isPvp ? "PvP is now on." : "PvP is now off."); + return; } if (name == "/kick") { @@ -360,9 +383,9 @@ void Chat::overhead_draw(Game& game) int member = member_get(game, i); if (!fighter.isAlive || member == NO_MEMBER) continue; - Color color = game.color_get(i, true); + Color color = game.color_get(i, TINT_PLAIN); float centerX = fighter.body.box.x + fighter.body.box.w / 2; - float y = fighter.body.box.y - above; + float y = game.scene.top_get(fighter) - above; bool isTyping = member == game.net.selfId ? isOpen : typing[member]; if (isTyping) { diff --git a/src/chat.h b/src/chat.h index 8bcf112..6c0a650 100644 --- a/src/chat.h +++ b/src/chat.h @@ -40,7 +40,7 @@ public: std::map typing; void update(Game& game); - void system(const std::string& text); + void system(const std::string& text, Color color = WHITE); void say(Game& game, int member, const std::string& text); void clear(); void players_clear(); @@ -48,8 +48,11 @@ public: void overhead_draw(Game& game); private: + std::map pvps; std::map> recent; + void pvps_announce(Game& game); + void open(Game& game); void close(Game& game); void submit(Game& game); diff --git a/src/common.h b/src/common.h index 765a12b..69325f5 100644 --- a/src/common.h +++ b/src/common.h @@ -44,6 +44,7 @@ struct Color 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}; +constexpr Color CLEAR = {0, 0, 0, 0}; inline Vec2 operator+(Vec2 a, Vec2 b) { @@ -184,6 +185,13 @@ inline Color color_fade(Color color, float alpha) return {color.r, color.g, color.b, color.a * alpha}; } +// A colour amount of the way from one to another. +inline Color color_mix(Color from, Color to, float amount) +{ + return {from.r + (to.r - from.r) * amount, from.g + (to.g - from.g) * amount, from.b + (to.b - from.b) * amount, + from.a + (to.a - from.a) * amount}; +} + // 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 diff --git a/src/dmath.h b/src/dmath.h index 5d4d627..0035ab1 100644 --- a/src/dmath.h +++ b/src/dmath.h @@ -5,11 +5,10 @@ #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. +// identically. The C library's sin, cos, atan2, pow and friends differ between platforms in their last bits, so these +// replace them everywhere. 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 diff --git a/src/effects.cpp b/src/effects.cpp index 17c5721..6afbfbb 100644 --- a/src/effects.cpp +++ b/src/effects.cpp @@ -12,8 +12,6 @@ constexpr float FLASH_CROSS = 0.5f; constexpr float FLASH_LENGTH = 16; constexpr float FLASH_LIFE = 0.05f; constexpr float FLASH_WIDTH = 6; -constexpr float RING_LIFE = 0.3f; -constexpr int RING_WIDTH = 2; constexpr float AFTERIMAGE_LIFE = 0.12f; constexpr float NUMBER_GAP = 8; constexpr float POPUP_FADE = 0.4f; @@ -57,10 +55,10 @@ void Effects::burst(Burst burst, Vec2 position, float angle, Color color) } // Callers may ask every tick. Copies are only kept every few ticks so trails stay spaced out. -void Effects::afterimage_spawn(Pose pose) +void Effects::afterimage_spawn(Pose pose, int worn) { if (ticks % AFTERIMAGE_INTERVAL == 0) - effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE}); + effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE, worn}); } // Drawn before the player and attacks so trails sit behind what made them. @@ -71,6 +69,7 @@ void Effects::afterimage_draw(Game& game) Pose pose = afterimage.pose; pose.placement.color = color_fade(pose.placement.color, AFTERIMAGE_ALPHA * afterimage.life / AFTERIMAGE_LIFE); pose.draw(game.renderer); + game.sprites.fashion_draw(game.renderer, pose, afterimage.worn); } } @@ -122,9 +121,21 @@ void Effects::flash_spawn(Vec2 position, float angle, float size, Color color) effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size}); } -void Effects::ring_spawn(Vec2 position, float radius, Color color) +// A particle at position, shrinking and fading as its life runs out: an outline in its colour around a core shaded by +// its fill. The outline is four strips so the fading core never shows it through. +void particle_draw(Renderer& renderer, Particle& particle, Vec2 position) { - effect_add(rings, Ring{color, position, RING_LIFE, radius}); + float fade = particle.life / particle.lifeMax; + float size = std::ceil(particle.size * fade); + float outer = size + PARTICLE_OUTLINE * 2; + 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)); } void Effects::dps_record(int amount) @@ -164,10 +175,6 @@ void Effects::update() flash.life -= TICK; std::erase_if(flashes, [](Flash& flash) { return flash.life <= 0; }); - for (Ring& ring : rings) - ring.life -= TICK; - std::erase_if(rings, [](Ring& ring) { return ring.life <= 0; }); - for (Afterimage& afterimage : afterimages) afterimage.life -= TICK; std::erase_if(afterimages, [](Afterimage& afterimage) { return afterimage.life <= 0; }); @@ -209,32 +216,8 @@ void Effects::draw(Game& game) renderer.line_draw(flash.position - across, flash.position + across, width * FLASH_CROSS, flash.color); } - // Each ring eases out to its full radius as it fades. - for (Ring& ring : rings) - { - float fade = ring.life / RING_LIFE; - float radius = ring.radius * (1 - fade * fade); - for (int i = 0; i < RING_WIDTH; i++) - renderer.ring_draw(ring.position, std::max(1.0f, radius - i), color_fade(ring.color, fade)); - } - - // 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)); - } + particle_draw(renderer, particle, particle.position - particle.velocity * behind); for (Popup& popup : popups) { @@ -258,6 +241,5 @@ void Effects::clear() hits.clear(); particles.clear(); popups.clear(); - rings.clear(); shake = 0; } diff --git a/src/effects.h b/src/effects.h index 0ddcc56..a7c1f6c 100644 --- a/src/effects.h +++ b/src/effects.h @@ -17,6 +17,8 @@ struct Particle float size; }; +void particle_draw(Renderer& renderer, Particle& particle, Vec2 position); + // 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 @@ -49,20 +51,13 @@ struct Flash float size = 0; }; -// A ring swelling out to its radius as it fades, like an airblast. -struct Ring -{ - Color color; - Vec2 position; - float life = 0; - float radius = 0; -}; - -// A fading copy of a sprite left behind by something moving fast. +// A fading copy of a sprite left behind by something moving fast, and what its fighter wore (nothing, FASHION_NONE, +// for an attack's). struct Afterimage { Pose pose; float life = 0; + int worn = 0; }; // Particles, afterimages, text popups, screen shake and the damage-per-second tally. @@ -70,9 +65,8 @@ class Effects { public: void burst(Burst burst, Vec2 position, float angle, Color color); - void afterimage_spawn(Pose pose); + void afterimage_spawn(Pose pose, int worn); void flash_spawn(Vec2 position, float angle, float size, Color color); - void ring_spawn(Vec2 position, float radius, Color color); void afterimage_draw(Game& game); void number_spawn(Vec2 position, int value, int target, Color color); void dps_record(int amount); @@ -90,7 +84,6 @@ private: std::vector hits; std::vector particles; std::vector popups; - std::vector rings; float shake = 0; int ticks = 0; }; diff --git a/src/fighter.cpp b/src/fighter.cpp index 984e971..a96a921 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -30,13 +30,13 @@ constexpr float JUMP_RELEASE_SPEED = 160; constexpr float KICK_COOLDOWN = 1.2f; constexpr float LAUNCH_DRAG = 160; constexpr float LAND_SPEED = 150; +// The most charge a normal attack can carry, just short of a full charge's charged attack. +constexpr float CHARGE_NORMAL_MAX = 0.999f; constexpr float MAX_FALL = 520; constexpr float ROPE_MIN = 24; constexpr float SLIDE_DUST_INTERVAL = 0.08f; constexpr float SWING_ACCELERATION = 500; constexpr float SWING_SPEED_MAX = 420; -constexpr float POUND_AREAS[LEVEL_COUNT] = {15, 20, 25}; -constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50}; constexpr float POUND_SCALE_MAX = 3; constexpr float POUND_SPEED = 700; constexpr Vec2 HAND_OFFSET = {4, 2}; @@ -47,7 +47,9 @@ 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 KICK_VELOCITY = {380, -260}; +constexpr float KICK_STACK_BOOST = 0.25f; +constexpr float HITSTOP_RUN_MAX = 1; constexpr Vec2 WALL_JUMP_VELOCITY = {180, -JUMP_SPEED}; constexpr float CROUCH_HEIGHT = 0.75f; constexpr float CROUCH_DAMP = 0.5f; @@ -108,6 +110,7 @@ void Fighter::position_set(Sim& sim, Vec2 spawn) safe = spawn; echoes.clear(); hitstop = 0; + hitstopRun = 0; isLaunched = false; } @@ -133,12 +136,12 @@ void Fighter::damage_take(Sim& sim, Damage damage) bool isRacing = sim.match.is_racing(); health -= isRacing || sim.match.state == MATCH_PRACTICE ? 0 : damage.amount; flash = 1; - hitstop = std::max(hitstop, damage.hitstop); + hitstop_add(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 * - (isRacing ? RACE_KNOCK : 1) * knock_absorb_get(); + (isRacing ? RACE_KNOCK : 1) * stat_get(STAT_KNOCKBACK_TAKEN); knock_take(direction * knock, damage.owner); if (health > 0) return; @@ -155,19 +158,24 @@ void Fighter::damage_take(Sim& sim, Damage damage) void Fighter::update(Sim& sim) { // Out of the round, it still keeps track of its buttons, so a held one (like Ready on the results) counts once. + // Its hitstop runs out too, or the attacks it left out would stay frozen with it. if (!isAlive) { + hitstop = std::max(0.0f, hitstop - TICK); buttonsPrevious = controls.buttons; return; } cornerPrevious = {body.box.x, body.box.y}; flash = std::max(0.0f, flash - FLASH_DECAY * TICK); shockCooldown -= TICK; + donkWindow -= TICK; if (hitstop > 0) { hitstop -= TICK; + hitstopRun += TICK; return; } + hitstopRun = 0; for (float& cooldown : cooldowns) cooldown = std::max(0.0f, cooldown - TICK); @@ -248,8 +256,8 @@ void Fighter::update(Sim& sim) { float impact = std::min(fallSpeed / POUND_SPEED, POUND_SCALE_MAX); sim.weapons.blast_spawn(sim, {.position = feet, - .area = trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact), - .damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact, + .area = stat_get(STAT_SLAM_AREA) * std::sqrt(impact), + .damage = stat_get(STAT_SLAM_DAMAGE) * impact, .owner = index, .sound = SOUND_POUND}); } @@ -309,8 +317,8 @@ 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); + float boots = stat_get(STAT_RUN_SPEED); + float runSpeed = RUN_SPEED * boots * (isCrouching ? CRAWL_SPEED : 1); if (dashTimer > 0) return; if (isLaunched) @@ -323,9 +331,9 @@ void Fighter::run_update(Attack* hook) bool isAirborne = !body.isGrounded && !hook; bool isFastFalling = isAirborne && is_down(CONTROL_DOWN) && !isSliding; bool isFloating = isAirborne && is_down(CONTROL_UP); - float weights = isFastFalling ? trinket_get(TRINKET_WEIGHTS, 1) : 1; + float weights = isFastFalling ? stat_get(STAT_FAST_FALL) : 1; float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1); - gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 1; + gravity *= isFloating ? stat_get(STAT_FLOAT_GRAVITY) : 1; gravity *= isSpinning ? SPIN_GRAVITY : 1; float maxFall = isFastFalling ? FAST_FALL_SPEED * weights : MAX_FALL; if (body.velocity.y > maxFall) @@ -382,7 +390,7 @@ 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); + reel = -hook.stats.speed * stat_get(STAT_MOVEMENT); hook.length = std::clamp(hook.length + reel * TICK, ROPE_MIN, hook.stats.range); hook.isPulling &= hook.length > ROPE_MIN; @@ -418,7 +426,7 @@ void Fighter::slot_update(Sim& sim) // 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); + scale = stat_get(STAT_SIZE); 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}; @@ -430,7 +438,7 @@ void Fighter::size_update() // works while there's a dash's worth left to build up before all of them are spent. void Fighter::dash_update(Sim& sim, bool isJumpPressed) { - float speed = trinket_best_get(TRINKET_WINGS, 0); + float speed = stat_get(STAT_DASH_SPEED); dashTimer -= TICK; dashCooldown = std::max(0.0f, dashCooldown - TICK); @@ -450,7 +458,7 @@ void Fighter::dash_update(Sim& sim, bool isJumpPressed) // off, on a cooldown so it can't climb forever. 3: Jump wall jumps freely. void Fighter::climb_update(Sim& sim) { - int level = trinket_get(TRINKET_CLIMBING, 0); + int level = stat_get(STAT_CLIMB); int wall = wall_get(sim); kickTimer -= TICK; slideWall = 0; @@ -469,9 +477,10 @@ void Fighter::climb_update(Sim& sim) } Control away = wall > 0 ? CONTROL_LEFT : CONTROL_RIGHT; + // Each copy past the first kicks off a quarter harder. if (level >= 2 && kickTimer <= 0 && is_pressed(away)) { - wall_push(sim, wall, KICK_VELOCITY); + wall_push(sim, wall, KICK_VELOCITY * (1 + KICK_STACK_BOOST * (trinket_count(TRINKET_CLIMBING) - 1))); kickTimer = KICK_COOLDOWN; isJumping = false; } @@ -600,9 +609,9 @@ void Fighter::charge_update(Sim& sim) bool isLeftPressed = is_pressed(CONTROL_ATTACK); bool isRightPressed = is_pressed(CONTROL_CHARGE); if (is_released(CONTROL_ATTACK)) - sim.weapons.wisps_release(*this); + sim.weapons.orbits_release(*this); - // A latched grapple lets go when clicked with the grapple in hand; another weapon fires as it hangs on. + // A latched grapple lets go when clicked with the grapple in hand. Another weapon fires as it hangs on. if (hook && hook->key == weapon_key(slot) && (isLeftPressed || isRightPressed)) { sim.weapons.attacks_release(*this, ATTACK_HOOK); @@ -615,8 +624,28 @@ void Fighter::charge_update(Sim& sim) return; } + // A full charge let go of during a cooldown goes first, the moment the weapon in hand is ready (see below). + // Attacking while charging spends the charge on the normal attack (as strong as the charge built, short of the + // charged one) once it's ready. A weapon whose charge just strengthens its shot lets it go as if released instead, + // and a Mine's detonator leaves the charge (the next mine) be. + int key = weapon_key(slot); + int weapon = weapon_get(); + bool isDetonator = weapon >= 0 && WEAPON_DEFS[weapon].normal.kind == ATTACK_DETONATE; + bool isReady = key < 0 || cooldowns[key] <= 0; + if (isChargeQueued && isReady) + { + sim.weapons.fire(sim, *this, 1); + isChargeQueued = false; + isReady = false; + } isFireBlocked &= is_down(CONTROL_ATTACK); - if (is_down(CONTROL_ATTACK) && !isFireBlocked) + if (is_down(CONTROL_ATTACK) && !isFireBlocked && isCharging && isReady && !isDetonator) + { + bool isScaled = weapon >= 0 && WEAPON_DEFS[weapon].isChargeScaled; + sim.weapons.fire(sim, *this, isScaled ? charge : std::min(charge, CHARGE_NORMAL_MAX)); + charge_cancel(sim); + } + else if (is_down(CONTROL_ATTACK) && !isFireBlocked) sim.weapons.fire(sim, *this, 0); if (isRightPressed && weapon_get() >= 0) @@ -625,7 +654,7 @@ void Fighter::charge_update(Sim& sim) 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)); + charge = std::min(1.0f, charge + TICK / CHARGE_TIME * stat_get(STAT_CHARGE_RATE)); if (previous < CHARGE_VISIBLE && charge >= CHARGE_VISIBLE) sim.cues->charge_play(*this); if (charge >= 1) @@ -636,29 +665,44 @@ void Fighter::charge_update(Sim& sim) } } + // Let go, the charge drops back to nothing and fires. A full charge let go of while the weapon in hand is cooling + // down waits, going the moment it's ready (see above). if (isCharging && is_released(CONTROL_CHARGE)) { - sim.weapons.fire(sim, *this, charge); sim.cues->charge_stop(*this); + bool isCooling = key >= 0 && cooldowns[key] > 0; + if (charge >= 1 && isCooling) + isChargeQueued = true; + else + sim.weapons.fire(sim, *this, charge); charge = 0; isCharging = false; } } // 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) +// have one, and every other trinket is always 0. Each Wings copy holds a dash of its own, the last copy's spent first +// and refilled first, and with no copy given it's whether the next dash is ready. +float Fighter::trinket_cooldown_get(int trinket, int copy) { 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); + copy = copy >= 0 ? copy : std::max(0, trinket_count(TRINKET_WINGS) - 1); + return std::clamp((dashCooldown - DASH_COOLDOWN * copy) / DASH_COOLDOWN, 0.0f, 1.0f); } + if (copy > 0) + return 0; if (trinket == TRINKET_CLIMBING) return std::max(0.0f, kickTimer) / KICK_COOLDOWN; return 0; } +// Freezes it in hitstop for at least amount, though never for more than a second in a row however much it's hit. +void Fighter::hitstop_add(float amount) +{ + hitstop = std::max(hitstop, std::min(amount, HITSTOP_RUN_MAX - hitstopRun)); +} + // The radius an Iron Weights slam would blast if the fighter landed at its current speed, or 0 if it isn't falling // fast enough to slam at all. float Fighter::pound_area_get() @@ -666,7 +710,7 @@ float Fighter::pound_area_get() if (!is_trinket_equipped(TRINKET_WEIGHTS) || body.isGrounded || body.velocity.y <= MAX_FALL) return 0; float impact = std::min(body.velocity.y / POUND_SPEED, POUND_SCALE_MAX); - return trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact); + return stat_get(STAT_SLAM_AREA) * std::sqrt(impact); } // Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports). @@ -679,9 +723,9 @@ void Fighter::teleport_to(Vec2 centre) // 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) +void Fighter::impulse_add(Vec2 impulse) { - body.velocity = body.velocity + impulse * (isDamped && isCrouching ? CROUCH_DAMP : 1); + body.velocity = body.velocity + impulse * (isCrouching ? CROUCH_DAMP : 1); isJumping = false; } @@ -693,7 +737,8 @@ void Fighter::knock_take(Vec2 knock, int owner) knock = knock * (isCrouching ? CROUCH_DAMP : 1); float strength = vec2_length(knock); Vec2 direction = vec2_normalize(knock); - impulse_add(direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction)), false); + body.velocity = body.velocity + direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction)); + isJumping = false; launcher = owner; isLaunched |= strength >= LAUNCH_START_SPEED; } @@ -711,6 +756,7 @@ void Fighter::charge_cancel(Sim& sim) sim.cues->charge_stop(*this); charge = 0; isCharging = false; + isChargeQueued = false; } // The weapon in the selected hotbar slot, or -1 if the slot is empty. @@ -738,12 +784,11 @@ const LevelDef& Fighter::level_get(int 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) +void Fighter::item_set(ItemKind kind, int itemSlot, int item) { - (isTrinket ? trinketLoadout : weaponLoadout)[itemSlot] = item; + loadout_get(kind)[itemSlot] = item; if (item >= 0) - (isTrinket ? trinketSlotLevels : weaponSlotLevels)[itemSlot] = - (isTrinket ? trinketItemLevels : weaponItemLevels)[item]; + slot_levels_get(kind)[itemSlot] = item_levels_get(kind)[item]; } // Every item and every copy at one level, as a match sets them each round. @@ -755,63 +800,44 @@ void Fighter::levels_fill(int weaponLevel, int 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) +// A trinket stat from the equipped copies (across trinkets, like Shrinker and Embiggener's sizes), or its neutral +// value without any. A lone copy counts exactly as it is. Otherwise, by the stat's stacking, the best copy counts, or +// what each adds beyond neutral adds up (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add) while what each +// takes away below it multiplies in (so Shrinkers never pass zero). The stat's cap holds the total. +float Fighter::stat_get(TrinketStat stat) { - const TrinketDef& def = TRINKET_DEFS[trinket]; - float total = neutral; - bool isFirst = true; + const TrinketStatDef& def = TRINKET_STATS[stat]; + float best = 0; + float gained = 0; + float kept = 1; + float only = 0; + int count = 0; for (int i = 0; i < trinketSlots; i++) { - if (trinketLoadout[i] != trinket) + int trinket = trinketLoadout[i]; + float value = trinket >= 0 ? TRINKET_DEFS[trinket].levels[trinketSlotLevels[i]].*def.field : 0; + if (value == 0) 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; + only = value; + count++; + best = std::max(best, value); + if (value < def.neutral) + kept *= value / def.neutral; + else + gained += value - def.neutral; } + float total = count == 0 ? def.neutral + : count == 1 ? only + : def.stack == STACK_BEST ? best + : (def.neutral + gained) * kept; 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) diff --git a/src/fighter.h b/src/fighter.h index ff8fe14..87581c6 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -16,18 +16,63 @@ constexpr int ECHO_MAX = 64; constexpr int MAX_FIGHTERS = SPAWN_MAX; constexpr float BOUNCE_KEEP = 0.75f; constexpr float SECONDARY_SHADE = 0.5f; -constexpr int COLOR_COUNT = 16; +constexpr int COLOR_COUNT = 23; 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}, + {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}, + {0.6f, 1, 0.8f, 1}, {0.45f, 0.45f, 1, 1}, {0.7f, 0.7f, 0.3f, 1}, {0.85f, 0.72f, 0.5f, 1}, + {1, 0.55f, 0.45f, 1}, {0.7f, 0.35f, 0.65f, 1}, {0.85f, 0.2f, 0.4f, 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 const char* COLOR_NAMES[COLOR_COUNT] = { + "Red", "Blue", "Yellow", "Green", "Orange", "Purple", "Cyan", "Pink", "Lime", "Teal", "Magenta", "Sky", + "Peach", "Lavender", "Brown", "Silver", "Mint", "Indigo", "Olive", "Tan", "Coral", "Plum", "Ruby"}; +// What a fighter can wear, one at a time, from the everyday to the fantastical: its name (its animation in +// fashion.anm2, laid out in the same order, each layer drawn on the player null it's named after, Head, Face or Torso) +// and its tooltip quote. The first is wearing nothing. +struct FashionDef +{ + const char* name; + const char* flavor; +}; + +constexpr int FASHION_NONE = 0; +constexpr FashionDef FASHION_DEFS[] = { + {"None", ""}, + {"Backwards Cap", "Wearing things incorrectly is cool."}, + {"Headphones", "Beat(ings) by Shweet"}, + {"Hard Hat", "Doesn't work here..."}, + {"Bandana", "Infinite ammo...but\nyou already have that."}, + {"Bow", "Stereotypical, yet cute!"}, + {"Moustache", "Clean shave."}, + {"Eyepatch", "Depth perception not needed."}, + {"Monocle", "Tea time!"}, + {"Top Hat", "Dashing."}, + {"Ten Gallon", "This town ain't big enough\nfor the two of us, pardner."}, + {"Fez", "Won't turn the game 3D."}, + {"Bling", "$$$"}, + {"Propeller Hat", ":D"}, + {"Headdress", "And where is your uniform?"}, + {"Bycocket", "That's what these things are called!"}, + {"Crown", "Heavy is the head\nthat wears the crown!"}, + {"Scoop", "Yummy!"}, + {"Petals", "Cosplay as a flower."}, + {"Witch Hat", "Potions, spells,\nwitchcraft, the works."}, + {"Knight Helmet", "+5 DEF. (Just kidding)."}, + {"Antennae", "Gets good reception."}, + {"Devil Horns", "King of Hell"}, + {"Demon Horns", "Pointy, dangerous and evil"}, +}; +constexpr int FASHION_COUNT = sizeof(FASHION_DEFS) / sizeof(FASHION_DEFS[0]); + +// An item to wear, or none for one that isn't (as from another version). +inline int fashion_clean(int item) +{ + return item > FASHION_NONE && item < FASHION_COUNT ? item : FASHION_NONE; +} constexpr float BOUNCE_MIN = 120; constexpr float BOX_HEIGHT = 24; constexpr float BOX_WIDTH = 12; @@ -39,8 +84,6 @@ 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 @@ -69,6 +112,11 @@ public: float flash = 0; float health = 0; float hitstop = 0; + float hitstopRun = 0; + // How much longer a blast from donkOwner's donkWeapon would hit it three times as hard (see Weapons::attack_hit). + float donkWindow = 0; + int donkOwner = -1; + int donkWeapon = -1; float kickFrom = 0; float kickTime = 1; float kickTo = 0; @@ -106,7 +154,7 @@ public: void damage_take(Sim& sim, Damage damage); void update(Sim& sim); void blend(float alpha); - void impulse_add(Vec2 impulse, bool isDamped = true); + void impulse_add(Vec2 impulse); void knock_take(Vec2 knock, int owner); void teleport_to(Vec2 centre); void burst_add(Vec2 impulse); @@ -117,15 +165,29 @@ public: int hand_slot_get(int hand); int slot_level_get(int weaponSlot); const LevelDef& level_get(int weaponSlot); - void item_set(bool isTrinket, int itemSlot, int item); + void item_set(ItemKind kind, int itemSlot, int item); + // A kind's slot count, loadout by slot, copies' levels by slot, and levels by item. + int& slots_get(ItemKind kind) + { + return kind == ITEM_TRINKET ? trinketSlots : weaponSlots; + } + int* loadout_get(ItemKind kind) + { + return kind == ITEM_TRINKET ? trinketLoadout : weaponLoadout; + } + int* slot_levels_get(ItemKind kind) + { + return kind == ITEM_TRINKET ? trinketSlotLevels : weaponSlotLevels; + } + int* item_levels_get(ItemKind kind) + { + return kind == ITEM_TRINKET ? trinketItemLevels : weaponItemLevels; + } 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); + float stat_get(TrinketStat stat); int trinket_count(int trinket); - float trinket_cooldown_get(int trinket); - float knock_absorb_get(); + float trinket_cooldown_get(int trinket, int copy = -1); + void hitstop_add(float amount); float pound_area_get(); Vec2 center_get(); Vec2 hand_get(int hand = 0); @@ -157,6 +219,7 @@ private: int slideWall = 0; Uint16 buttonsPrevious = 0; bool isCharging = false; + bool isChargeQueued = false; bool isFireBlocked = false; bool isFacingLeft = false; bool isJumping = false; diff --git a/src/game.h b/src/game.h index cbc33de..aaf0da8 100644 --- a/src/game.h +++ b/src/game.h @@ -19,6 +19,14 @@ constexpr float PVP_OFF_SHADE = 0.4f; +// How a fighter's colour is asked for: as it shows in the world (darkened when PvP keeps it out of this screen's +// fights), or plain, as the HUD shows it. +enum Tint +{ + TINT_PVP, + TINT_PLAIN +}; + // 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). Sim is the game being // played, saved in snapshots. Local is the fighter this screen @@ -76,11 +84,11 @@ struct Game } // 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 + // (picked in Profile, 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) + // plain (TINT_PLAIN, as the HUD's tint is), it's never darkened. + Color color_get(int fighter, Tint tint = TINT_PVP) { if (sim.match.state == MATCH_PRACTICE && sim.fighterCount == 1) { @@ -89,9 +97,16 @@ struct Game } Color color = PALETTE[sim.match.colors[fighter]]; bool isHostile = sim.fighters[fighter].isPvp && sim.fighters[local].isPvp; - bool isPeaceful = !isPlain && fighter != local && sim.match.state == MATCH_PRACTICE && !isHostile; + bool isPeaceful = tint == TINT_PVP && fighter != local && sim.match.state == MATCH_PRACTICE && !isHostile; return isPeaceful ? color_shade(color, PVP_OFF_SHADE) : color; } + + // What a fighter wears: in the sandbox alone, what Profile has picked, otherwise what the match has them in. + int fashion_get(int fighter) + { + bool isAlone = sim.match.state == MATCH_PRACTICE && sim.fighterCount == 1; + return isAlone ? settings.fashion : sim.match.fashions[fighter]; + } }; void game_tick(Game& game); diff --git a/src/input.cpp b/src/input.cpp index 8031f9b..ce60a6d 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -17,6 +17,8 @@ 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}; +// The arrow keys move too, whatever the movement controls are bound to, in Control's order. +constexpr SDL_Scancode MOVE_KEYS[] = {SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, SDL_SCANCODE_UP, SDL_SCANCODE_DOWN}; 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}; @@ -161,7 +163,7 @@ void Input::poll(Game& game) isMouseMoved = true; break; case SDL_EVENT_MOUSE_WHEEL: - if (isCapturing && !isCaptureGamepad) + if (isCapturing && captureSide == SIDE_KEY) capture_take(event.wheel.y > 0 ? BIND_WHEEL_UP : BIND_WHEEL_DOWN); else { @@ -174,14 +176,14 @@ void Input::poll(Game& game) int button = event.button.button == SDL_BUTTON_LEFT ? MOUSE_LEFT : event.button.button == SDL_BUTTON_RIGHT ? MOUSE_RIGHT : MOUSE_MIDDLE; - if (isCapturing && !isCaptureGamepad) + if (isCapturing && captureSide == SIDE_KEY) capture_take(BIND_MOUSE + button); else presses[button] = true; break; } case SDL_EVENT_GAMEPAD_BUTTON_DOWN: - if (isCapturing && isCaptureGamepad) + if (isCapturing && captureSide == SIDE_PAD) capture_take(event.gbutton.button); else if (isCapturing) { @@ -197,7 +199,7 @@ void Input::poll(Game& game) } break; case SDL_EVENT_GAMEPAD_AXIS_MOTION: - if (isCapturing && isCaptureGamepad && std::abs(event.gaxis.value / AXIS_MAX) > AXIS_THRESHOLD) + if (isCapturing && captureSide == SIDE_PAD && 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: @@ -208,7 +210,7 @@ void Input::poll(Game& game) { if (scancode == SDL_SCANCODE_ESCAPE) capture_take(-2); - else if (!isCaptureGamepad) + else if (captureSide == SIDE_KEY) capture_take(scancode); break; } @@ -219,8 +221,9 @@ void Input::poll(Game& game) erases += isTyping && scancode == SDL_SCANCODE_BACKSPACE; isSubmitted |= isTyping && (scancode == SDL_SCANCODE_RETURN || scancode == SDL_SCANCODE_ESCAPE); isCancelled |= isTyping && scancode == SDL_SCANCODE_ESCAPE; + // A number key picks a slot, or with Shift held, a loadout to load. if (!isTyping && scancode >= SDL_SCANCODE_1 && scancode < SDL_SCANCODE_1 + SLOT_KEY_COUNT) - slotPressed = scancode - SDL_SCANCODE_1; + (event.key.mod & SDL_KMOD_SHIFT ? loadoutPressed : slotPressed) = scancode - SDL_SCANCODE_1; if (!isTyping && scancode == SDL_SCANCODE_F && !event.key.repeat) game.settings.fullscreen_toggle(game); break; @@ -326,8 +329,7 @@ void Input::capture_take(int binding) { if (binding != -2) { - int& side = isCaptureGamepad ? bindings[captureControl].pad : bindings[captureControl].key; - side = binding; + bindings[captureControl].side_get(captureSide) = binding; isBindingChanged = true; } captureControl = -1; @@ -397,13 +399,14 @@ void Input::tick_end() } // 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. +// (or arrow key, for moving) 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); + bool isDown = + is_key_binding_down(binding.key) || (control <= CONTROL_DOWN && is_key_binding_down(MOVE_KEYS[control])); for (int i = DEVICE_KEYBOARD + 1; i < DEVICE_COUNT; i++) isDown |= !devices[i].isClaimed && is_pad_binding_down(devices[i], binding.pad); return isDown; @@ -484,10 +487,19 @@ bool Input::is_pad_binding_down(Device& device, int binding) return (binding - BIND_AXIS) % 2 ? value > AXIS_THRESHOLD : value < -AXIS_THRESHOLD; } +// A control's binding as shown in hints: its gamepad side while the keyboard's device aims with a stick, otherwise its +// keyboard side. +void Input::control_name_get(Control control, char* text, int size) +{ + BindingSide side = devices[DEVICE_KEYBOARD].isAiming ? SIDE_PAD : SIDE_KEY; + binding_name_get(bindings[control].side_get(side), side, text, size); +} + // 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) +void Input::binding_name_get(int binding, BindingSide side, char* text, int size) { + bool isGamepad = side == SIDE_PAD; if (binding < 0) SDL_strlcpy(text, "-", size); else if (isGamepad && binding >= BIND_AXIS) diff --git a/src/input.h b/src/input.h index e02bc53..f3b840e 100644 --- a/src/input.h +++ b/src/input.h @@ -59,12 +59,24 @@ enum MouseButton MOUSE_COUNT }; +// Which half of a binding: the keyboard side or the gamepad side. +enum BindingSide +{ + SIDE_KEY, + SIDE_PAD +}; + // 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; + + int& side_get(BindingSide side) + { + return side == SIDE_PAD ? pad : key; + } }; constexpr int PAD_MAX = 4; @@ -144,8 +156,10 @@ public: int captureControl = -1; int erases = 0; int slotPressed = -1; + // A loadout picked with Shift and a number key, kept until the settings take it (they update once a frame). + int loadoutPressed = -1; bool isBindingChanged = false; - bool isCaptureGamepad = false; + BindingSide captureSide = SIDE_KEY; bool isClicked = false; bool isConfirmed = false; bool isMouseMoved = false; @@ -174,7 +188,8 @@ public: 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); + void binding_name_get(int binding, BindingSide side, char* text, int size); + void control_name_get(Control control, char* text, int size); bool is_control_down(int device, Control control); diff --git a/src/main.cpp b/src/main.cpp index 42d1c40..f93f3a2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -60,6 +60,7 @@ void game_tick(Game& game) match.isSandboxRequested = game.menu.isLeaving || (!game.menu.isOpen && input.is_pad_tapped(SDL_GAMEPAD_BUTTON_EAST)); match.colorWanted = game.settings.color_get(); + match.fashionWanted = game.settings.fashion; game.sim.tick(); input.tick_end(); } @@ -122,9 +123,8 @@ struct FrameClock 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) +// One frame's input and as many fixed ticks as the time since the last frame allows. +static void frame_step() { Uint64 now = SDL_GetPerformanceCounter(); frameClock.accumulator += @@ -143,7 +143,7 @@ static bool frame_run(bool isDrawn = true) if (session.is_wanted(game)) { Fighter& own = game.local_get(); - bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching(); + bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching(game); Controls controls = game.input.controls_get(DEVICE_KEYBOARD, game.camera, own, isHeld); session.update(game, controls); game.input.tick_end(); @@ -167,8 +167,13 @@ static bool frame_run(bool isDrawn = true) } game.settings.update(game); game.alpha = frameClock.accumulator / TICK; - if (isDrawn) - game_draw(game); +} + +// One frame, stepped and drawn. Returns whether to keep going. +static bool frame_run() +{ + frame_step(); + game_draw(game); return !game.input.isQuit; } @@ -184,7 +189,7 @@ static void frame_callback() extern "C" EMSCRIPTEN_KEEPALIVE void frame_hidden() { if (game.session.is_wanted(game)) - frame_run(false); + frame_step(); } #endif diff --git a/src/match.cpp b/src/match.cpp index 778dfc6..c88e85a 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -4,10 +4,12 @@ #include #include +// How often a bot wears something. +constexpr float BOT_FASHION_CHANCE = 0.5f; constexpr float TOMATO_COOLDOWN = 0.25f; 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 ROUND_OVER_TIME = 4; constexpr float SECONDS_PER_MINUTE = 60; constexpr int RACE_PLAYERS = 4; constexpr int RACE_TIMER = 3; @@ -46,18 +48,20 @@ Rules rules_clean(Rules rules) return rules; } -// Fills a loadout's empty slots with random picks out of count items, none repeating what's already in it. Returns -// whether it filled any. -static bool picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDuplicates) +// Fills a fighter's empty slots of one kind with random picks, none repeating what's already in them unless the +// rules allow duplicates. Returns whether it filled any. +static bool picks_deal(Rng& rng, Fighter& fighter, ItemKind kind, ItemRules& items) { + int* loadout = fighter.loadout_get(kind); + int slots = fighter.slots_get(kind); bool isDealt = false; 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); + int pick = rng.random_get(0, item_count_get(kind)); + while (!items.isDuplicates && std::find(loadout, loadout + slots, pick) != loadout + slots) + pick = rng.random_get(0, item_count_get(kind)); loadout[i] = pick; isDealt = true; } @@ -65,9 +69,10 @@ static bool picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDupl } // 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) +static bool is_pick_round(ItemRules& items, int round, RoundOpening opening) { - return isFirst || items.timing == PICK_EVERY_ROUND || (items.timing == PICK_PERIOD && round % items.period == 0); + return opening == ROUND_FIRST || items.timing == PICK_EVERY_ROUND || + (items.timing == PICK_PERIOD && round % items.period == 0); } static int level_get(ItemRules& items, int round) @@ -186,7 +191,7 @@ void Match::update(Sim& sim) break; } round += winner >= 0; - round_start(sim, false); + round_start(sim, ROUND_NEXT); break; } } @@ -207,6 +212,7 @@ void Match::start(Sim& sim) sim.bot.graph_build(sim.world); std::fill(std::begin(wins), std::end(wins), 0); round = 0; + tomatoes_clear(); // Offline, the fighters added beyond the sandbox's are bots. Online (a queued game), the bots are whoever no member // plays, and everyone's colour was settled already. @@ -218,8 +224,17 @@ void Match::start(Sim& sim) } if (!isOnline) colors_assign(sim); + // Half the bots wear something, picked at random. + for (int i = 0; i < sim.fighterCount; i++) + { + if (!sim.fighters[i].isBot) + continue; + bool isDressed = sim.rng.random_get(0, 1) < BOT_FASHION_CHANCE; + int item = std::min(FASHION_COUNT - 1, FASHION_NONE + 1 + (int)sim.rng.random_get(0, FASHION_COUNT - 1)); + fashions[i] = isDressed ? item : FASHION_NONE; + } sim.cues->match_start(); - round_start(sim, true); + round_start(sim, ROUND_FIRST); } // Another player on this screen joins the sandbox, starting from the first fighter's sandbox loadout and settings, with @@ -261,12 +276,15 @@ void Match::sandbox_leave(Sim& sim, int index) } // 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 the first fighter asks for now (offline, this screen's). +// ask for the same), then the rest in palette order. Only the first fighter asks for now (offline, this screen's), and +// only it wears anything. void Match::colors_assign(Sim& sim) { int wanted[MAX_FIGHTERS]; std::fill(std::begin(wanted), std::end(wanted), COLOR_UNASSIGNED); wanted[0] = colorWanted; + std::fill(std::begin(fashions), std::end(fashions), FASHION_NONE); + fashions[0] = fashion_clean(fashionWanted); bool isTaken[COLOR_COUNT] = {}; for (int i = 0; i < MAX_FIGHTERS; i++) { @@ -288,44 +306,36 @@ void Match::colors_assign(Sim& sim) // 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(Sim& sim, bool isFirst) +void Match::round_start(Sim& sim, RoundOpening opening) { - isWeaponPick = is_pick_round(rules.weapons, round, isFirst); - isTrinketPick = is_pick_round(rules.trinkets, round, isFirst); + for (ItemKind kind : ITEM_KINDS) + isPicks[kind] = is_pick_round(rules.items_get(kind), round, opening); sim.weapons.attacks.clear(); for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.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) + for (ItemKind kind : ITEM_KINDS) { - 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 (!isPicks[kind]) + continue; + ItemRules& items = rules.items_get(kind); + std::fill(fighter.loadout_get(kind), fighter.loadout_get(kind) + fighter.slots_get(kind), -1); + bool isDealt = items.isMirror ? i == 0 && !items.isChoice : !items.isChoice || fighter.isBot; if (isDealt) - picks_deal(sim.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(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, - rules.trinkets.isDuplicates); + picks_deal(sim.rng, fighter, kind, items); } fighter.state_reset(sim, health_get()); // Someone who left a queued game stays out of it. fighter.isAlive &= !isGone[i]; } - if (isWeaponPick && rules.weapons.isMirror) - mirror_copy(sim, false); - if (isTrinketPick && rules.trinkets.isMirror) - mirror_copy(sim, true); - isWeaponPick &= rules.weapons.isChoice; - isTrinketPick &= rules.trinkets.isChoice; + for (ItemKind kind : ITEM_KINDS) + { + if (isPicks[kind] && rules.items_get(kind).isMirror) + mirror_copy(sim, kind); + isPicks[kind] &= rules.items_get(kind).isChoice; + } std::fill(std::begin(isReady), std::end(isReady), false); std::fill(std::begin(finishTimes), std::end(finishTimes), -1.0f); @@ -334,16 +344,15 @@ void Match::round_start(Sim& sim, bool isFirst) // 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); + for (ItemKind kind : ITEM_KINDS) + countdown += isPicks[kind] ? CHOICE_SLOT_TIME * (rules.items_get(kind).slots - 1) : 0; 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. +// Applies the edit in a fighter's controls, if any (see Edit). In a match only Choice picks count, and only until the +// fighter readies: 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(Sim& sim, Fighter& fighter) { int code = fighter.controls.edit; @@ -352,38 +361,37 @@ void Match::edit_take(Sim& sim, Fighter& fighter) 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; + ItemKind kind = edit.kind; + int count = fighter.slots_get(kind); + int items = item_count_get(kind); + int* loadout = fighter.loadout_get(kind); 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)); + bool isPicking = isSandbox || (is_picking(kind) && !isReady[fighter.index]); 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); + fighter.item_set(kind, edit.index, item); return; } - ItemRules& kind = isTrinket ? rules.trinkets : rules.weapons; - for (int i = 0; i < count && !kind.isDuplicates && item >= 0; i++) + ItemRules& items = rules.items_get(kind); + for (int i = 0; i < count && !items.isDuplicates && item >= 0; i++) loadout[i] = loadout[i] == item ? -1 : loadout[i]; loadout[edit.index] = item; - if (kind.isMirror) + if (items.isMirror) for (int i = 0; i < sim.fighterCount; i++) - std::copy(loadout, loadout + count, - isTrinket ? sim.fighters[i].trinketLoadout : sim.fighters[i].weaponLoadout); + std::copy(loadout, loadout + count, sim.fighters[i].loadout_get(kind)); return; } case EDIT_SLOT_LEVEL: if (isSandbox && edit.index < count && loadout[edit.index] >= 0) { - int& copy = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[edit.index]; + int& copy = fighter.slot_levels_get(kind)[edit.index]; level_step(sim, copy, level - copy); } return; @@ -391,11 +399,11 @@ void Match::edit_take(Sim& sim, Fighter& fighter) { if (!isSandbox || edit.index >= items) return; - int& itemLevel = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[edit.index]; + int& itemLevel = fighter.item_levels_get(kind)[edit.index]; level_step(sim, itemLevel, level - itemLevel); for (int i = 0; i < SLOT_MAX; i++) if (loadout[i] == edit.index) - (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i] = itemLevel; + fighter.slot_levels_get(kind)[i] = itemLevel; return; } case EDIT_SLOTS: @@ -415,9 +423,21 @@ void Match::edit_take(Sim& sim, Fighter& fighter) colors[fighter.index] = edit.value; return; } + case EDIT_FASHION: + if (isSandbox) + fashions[fighter.index] = fashion_clean(edit.value); + return; } } +// Every tomato splatted goes, and everyone can throw again at once: a fresh match, or back to the sandbox. +void Match::tomatoes_clear() +{ + tomatoCount = 0; + std::fill(std::begin(tomatoes), std::end(tomatoes), Tomato{}); + std::fill(std::begin(tomatoCooldowns), std::end(tomatoCooldowns), 0.0f); +} + // After a round and on the results, everyone but the winner can splat tomatoes where they click, for show, a few a // second at most. void Match::tomatoes_update(Sim& sim) @@ -437,17 +457,11 @@ void Match::tomatoes_update(Sim& sim) } // Copies the first fighter's loadout of one kind of item to every other fighter. -void Match::mirror_copy(Sim& sim, bool isTrinket) +void Match::mirror_copy(Sim& sim, ItemKind kind) { Fighter& first = sim.fighters[0]; for (int i = 1; i < sim.fighterCount; i++) - { - Fighter& fighter = sim.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); - } + std::copy(first.loadout_get(kind), first.loadout_get(kind) + SLOT_MAX, sim.fighters[i].loadout_get(kind)); } // Deals a fighter's empty slots at random, returning whether it filled any. A mirrored kind is dealt only to the @@ -456,20 +470,14 @@ bool Match::loadout_deal(Sim& sim, int index) { Fighter& fighter = sim.fighters[index]; bool isDealt = false; - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; - ItemRules& items = isTrinket ? rules.trinkets : rules.weapons; + ItemRules& items = rules.items_get(kind); if (items.isMirror && index > 0) continue; - if (isTrinket) - isDealt |= - picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, items.isDuplicates); - else - isDealt |= - picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, items.isDuplicates); + isDealt |= picks_deal(sim.rng, fighter, kind, items); if (items.isMirror) - mirror_copy(sim, isTrinket); + mirror_copy(sim, kind); } return isDealt; } @@ -480,8 +488,7 @@ void Match::fight_start(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) loadout_deal(sim, i); - isWeaponPick = false; - isTrinketPick = false; + std::fill(std::begin(isPicks), std::end(isPicks), false); clock = 0; state = MATCH_FIGHT; } @@ -590,12 +597,12 @@ void Match::race_update(Sim& sim) void Match::sandbox_return(Sim& sim) { state = MATCH_PRACTICE; - isWeaponPick = false; - isTrinketPick = false; + std::fill(std::begin(isPicks), std::end(isPicks), false); sim.fighterCount = 1; sim.world.sandbox_load(); sim.bot.graph_build(sim.world); sim.weapons.attacks.clear(); + tomatoes_clear(); sim.cues->sandbox_return(); Fighter& fighter = sim.fighters[0]; @@ -847,6 +854,7 @@ int Match::member_join(Sim& sim, int member, const Profile& profile) isTaken[colors[i]] = true; bool isFree = profile.color >= 0 && profile.color < COLOR_COUNT && !isTaken[profile.color]; colors[index] = isFree ? profile.color : (std::find(isTaken, isTaken + COLOR_COUNT, false) - isTaken) % COLOR_COUNT; + fashions[index] = fashion_clean(profile.fashion); return index; } @@ -867,6 +875,7 @@ void Match::member_leave(Sim& sim, int member) sim.bot.states[i] = sim.bot.states[i + 1]; owners[i] = owners[i + 1]; colors[i] = colors[i + 1]; + fashions[i] = fashions[i + 1]; std::memcpy(names[i], names[i + 1], sizeof(names[i])); } queue_slots_shift(index, sim.fighterCount); diff --git a/src/match.h b/src/match.h index 0961651..37eee58 100644 --- a/src/match.h +++ b/src/match.h @@ -83,6 +83,10 @@ struct Rules bool isBotFill = false; bool operator==(const Rules&) const = default; + ItemRules& items_get(ItemKind kind) + { + return kind == ITEM_TRINKET ? trinkets : weapons; + } int wins_get() const { return bestOf / 2 + 1; @@ -103,13 +107,14 @@ enum EditType EDIT_PVP, EDIT_COLOR, // Online, leaving a queued game (forfeiting it if it's still on). - EDIT_LEAVE + EDIT_LEAVE, + EDIT_FASHION }; struct Edit { int type = EDIT_ITEM; - bool isTrinket = false; + ItemKind kind = ITEM_WEAPON; int index = 0; int value = 0; }; @@ -124,22 +129,21 @@ constexpr int EDIT_TYPE_SHIFT = EDIT_KIND_SHIFT + 1; 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; + edit.kind << 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, + .kind = (ItemKind)(code >> EDIT_KIND_SHIFT & 1), .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) +inline int pick_encode(ItemKind kind, int slot, int item) { - return edit_encode( - {.type = EDIT_ITEM, .isTrinket = isTrinket, .index = slot, .value = item < 0 ? EDIT_EMPTY : item}); + return edit_encode({.type = EDIT_ITEM, .kind = kind, .index = slot, .value = item < 0 ? EDIT_EMPTY : item}); } // A player's setup as they join online: their name, the colour they'd like (-1 for any), and their sandbox slots, @@ -148,6 +152,7 @@ struct Profile { char name[PLAYER_NAME_MAX + 1] = {}; int color = -1; + int fashion = FASHION_NONE; int weaponSlots = SLOTS_DEFAULT; int trinketSlots = SLOTS_DEFAULT; int weaponLoadout[SLOT_MAX] = {}; @@ -187,6 +192,13 @@ struct Tomato int serial = 0; }; +// Whether a round opens a match (when every kind is handed out) or follows another. +enum RoundOpening +{ + ROUND_FIRST, + ROUND_NEXT +}; + // 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 @@ -199,6 +211,15 @@ struct Tomato class Match { public: + // Whether a kind's items (or any) are being chosen this countdown. + bool is_picking(ItemKind kind) + { + return state == MATCH_COUNTDOWN && isPicks[kind]; + } + bool is_choosing() + { + return is_picking(ITEM_WEAPON) || is_picking(ITEM_TRINKET); + } Rules rules = rules_get(MODE_DEATHMATCH); MatchState state = MATCH_PRACTICE; float clock = 0; @@ -214,6 +235,8 @@ public: int winner = -1; int wins[MAX_FIGHTERS] = {}; int colors[MAX_FIGHTERS]; + // What each fighter wears. + int fashions[MAX_FIGHTERS] = {}; // 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]; @@ -229,11 +252,12 @@ public: bool isForfeited[MAX_FIGHTERS] = {}; // After a queued game, how many ticks everyone still in it has been set to play it again for. int againTicks = 0; - bool isTrinketPick = false; - bool isWeaponPick = false; + // Whether each kind of item is handed out this round (and with Choice, being chosen during the countdown). + bool isPicks[ITEM_KIND_COUNT] = {}; // Offline, what this screen asks of the next tick: the menu's Play, leaving the results for the sandbox, and the // colour its player picked. Online, members' wishes go through the session instead. int colorWanted = COLOR_UNASSIGNED; + int fashionWanted = FASHION_NONE; bool isOnline = false; bool isPlayRequested = false; bool isSandboxRequested = false; @@ -262,11 +286,12 @@ public: int humans_left_get(Sim& sim); private: - void round_start(Sim& sim, bool isFirst); + void round_start(Sim& sim, RoundOpening opening); void fight_start(Sim& sim); void edit_take(Sim& sim, Fighter& fighter); - void mirror_copy(Sim& sim, bool isTrinket); + void mirror_copy(Sim& sim, ItemKind kind); void tomatoes_update(Sim& sim); + void tomatoes_clear(); bool loadout_deal(Sim& sim, int fighter); void deathmatch_update(Sim& sim); void race_update(Sim& sim); diff --git a/src/menu.cpp b/src/menu.cpp index 7cb4fcd..a6fb86b 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -32,18 +32,21 @@ constexpr float PLAY_HEIGHT = 22; constexpr float RADIO_GAP = 12; constexpr float ROW_HEIGHT = 18; constexpr float TEXT_ROW_INSET = 1; +constexpr float MINI_PLAYER_WIDTH = 16; +constexpr float MINI_PLAYER_ROW = 34; +constexpr float GAME_ROW = 28; // The lobby's player list, when crowded into two columns: the gap between its tiny rows, and the widest ping it leaves // room for. -constexpr float COMPACT_ROW_GAP = 2; constexpr const char* PING_SAMPLE = "999ms"; constexpr int LABEL_LENGTH = 64; constexpr float SCROLL_HEAD_MIN = 8; constexpr float SCROLL_WIDTH = 6; +constexpr float WHEEL_ROWS = 2; 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* TAB_NAMES[TAB_COUNT] = {"Weapons", "Trinkets", "Play", "Profile", "Settings", "About"}; constexpr const char* MODE_NAMES[] = {"Deathmatch", "Race"}; constexpr const char* PICK_NAMES[] = {"Random", "Choice"}; constexpr const char* TIMING_NAMES[] = {"First Round", "Every Round", "Period"}; @@ -71,7 +74,7 @@ void Menu::update(Game& game) // choosing closes it too, back to the countdown with the loadout's tooltips. Match& match = game.sim.match; sandbox_levels_update(game); - bool isPicking = match.state == MATCH_COUNTDOWN && (match.isWeaponPick || match.isTrinketPick); + bool isPicking = match.is_choosing(); if (isPicking && !wasPicking && game.local_count() == 1) choice_open(game); if (!isPicking && wasPicking) @@ -83,7 +86,7 @@ void Menu::update(Game& game) wasReady = isReady; // Escape works as the menu control too. On a match's results (with the menu closed), either goes back to the // sandbox instead, the menu control so there's a way that browsers don't keep for leaving fullscreen. Online, - // that's leaving the queued game. Watching one, Escape or the menu control goes back to the lobby instead. + // that's leaving the queued game. Session& session = game.session; bool isEscapePressed = input.is_pressed(SDL_SCANCODE_ESCAPE); bool isMenuPressed = input.is_control_pressed(CONTROL_MENU); @@ -127,13 +130,13 @@ void Menu::update(Game& game) } if (tab >= ITEM_TABS) return; - bool isTrinketTab = tab == TAB_TRINKETS; + ItemKind tabKind = (ItemKind)tab; - int& selection = selections[isTrinketTab]; - int& scroll = scrolls[isTrinketTab]; + int& selection = selections[tab]; + int& scroll = scrolls[tab]; Fighter& fighter = game.local_get(); - int slots = isTrinketTab ? fighter.trinketSlots : fighter.weaponSlots; - bool isChoosable = is_choosable(game, isTrinketTab); + int slots = fighter.slots_get(tabKind); + bool isChoosable = is_choosable(game, tabKind); for (int i = 0; i < item_count() && isLeft; i++) { if (!is_point_in_rect(mouse, box_rect(i))) @@ -146,8 +149,13 @@ void Menu::update(Game& game) else game.audio.play(SOUND_SELECT); } + // Every wheel notch scrolls a few rows, and any turn at least one. 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)); + { + int rows = (int)std::round(-input.wheel * WHEEL_ROWS); + rows = rows != 0 ? rows : (input.wheel > 0 ? -1 : 1); + scroll = std::clamp(scroll + rows, 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; @@ -162,34 +170,33 @@ void Menu::update(Game& game) slot_assign(game, input.slotPressed); pinnedSlot = -1; } - for (int kind = 0; kind < ITEM_TABS; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == TAB_TRINKETS; - int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int count = fighter.slots_get(kind); for (int index = 0; index < count; index++) { - if (!is_point_in_rect(mouse, slot_rect(isTrinket, index, count))) + if (!is_point_in_rect(mouse, slot_rect(kind, 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]; + int item = fighter.loadout_get(kind)[index]; // One of this tab's own slots becomes the target, and shows what's in it. - if (isLeft && isTrinket == isTrinketTab && isChoosable) + if (isLeft && kind == tabKind && isChoosable) { targets[tab] = index; pinnedSlot = item >= 0 ? index : -1; pinnedTab = tab; - isPinnedTrinket = isTrinket; + pinnedKind = kind; game.audio.play(SOUND_SELECT); } else if (isLeft && item >= 0) { pinnedSlot = index; pinnedTab = tab; - isPinnedTrinket = isTrinket; + pinnedKind = kind; game.audio.play(SOUND_SELECT); } - if (isRight && is_choosable(game, isTrinket)) - slot_clear(game, isTrinket, index); + if (isRight && is_choosable(game, kind)) + slot_clear(game, kind, index); } } @@ -205,32 +212,30 @@ void Menu::level_step(Game& game, int step, Vec2 mouse) { if (game.sim.match.state != MATCH_PRACTICE) { - if (is_choosable(game, tab == TAB_TRINKETS)) + if (is_choosable(game, (ItemKind)tab)) return; - int& level = sandbox_levels_get(tab == TAB_TRINKETS)[selections[tab]]; + int& level = sandbox_levels_get((ItemKind)tab)[selections[tab]]; int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); isLevelsPending |= next != level; level = next; return; } Fighter& fighter = game.local_get(); - for (int kind = 0; kind < ITEM_TABS; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == TAB_TRINKETS; - int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int count = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); 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); + if (loadout[i] >= 0 && is_point_in_rect(mouse, slot_rect(kind, i, count))) + return slot_level_step(game, kind, i, step); } - bool isTrinket = tab == TAB_TRINKETS; + ItemKind kind = (ItemKind)tab; int item = selections[tab]; - int level = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item]; + int level = fighter.item_levels_get(kind)[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})); + game.input.edit_send(game.devices[game.local], + edit_encode({.type = EDIT_ITEM_LEVEL, .kind = kind, .index = item, .value = next})); } // Keeps the sandbox's item levels while in it. Back from a match that levelled them, they go back in as edits instead, @@ -240,12 +245,11 @@ void Menu::sandbox_levels_update(Game& game) if (game.sim.match.state != MATCH_PRACTICE) return; Fighter& fighter = game.local_get(); - for (int kind = 0; kind < ITEM_TABS; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == TAB_TRINKETS; - int* levels = sandbox_levels_get(isTrinket); - int* own = isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels; - int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + int* levels = sandbox_levels_get(kind); + int* own = fighter.item_levels_get(kind); + int count = item_count_get(kind); for (int item = 0; item < count && isLevelsPending; item++) { if (levels[item] == own[item]) @@ -253,9 +257,7 @@ void Menu::sandbox_levels_update(Game& game) int device = game.devices[game.local]; if (game.input.devices[device].editCount == 0) game.input.edit_send( - device, - edit_encode( - {.type = EDIT_ITEM_LEVEL, .isTrinket = isTrinket, .index = item, .value = levels[item]})); + device, edit_encode({.type = EDIT_ITEM_LEVEL, .kind = kind, .index = item, .value = levels[item]})); return; } if (!isLevelsPending) @@ -264,21 +266,20 @@ void Menu::sandbox_levels_update(Game& game) isLevelsPending = false; } -int* Menu::sandbox_levels_get(bool isTrinket) +int* Menu::sandbox_levels_get(ItemKind kind) { - return isTrinket ? sandboxTrinketLevels : sandboxWeaponLevels; + return kind == ITEM_TRINKET ? sandboxTrinketLevels : sandboxWeaponLevels; } // 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) +void Menu::slot_level_step(Game& game, ItemKind kind, int slot, int step) { Fighter& fighter = game.local_get(); - int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[slot]; + int level = fighter.slot_levels_get(kind)[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})); + game.input.edit_send(game.devices[game.local], + edit_encode({.type = EDIT_SLOT_LEVEL, .kind = kind, .index = slot, .value = next})); } // Dims the world, then draws the panel with its tabs (and the match's buttons) and the open tab. @@ -307,13 +308,16 @@ void Menu::draw(Game& game) 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 if (tab == TAB_PROFILE) + game.settings.profile_draw(game, layout.body, pages[TAB_PROFILE]); + else if (tab == TAB_ABOUT) + about_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); + ui.hint_draw(game); } // The gamepad's menu buttons: the bumpers step through the tabs shown, B goes back from the rules or closes the menu, @@ -375,26 +379,75 @@ void Menu::hints_draw(Game& game, MenuLayout& layout) void Menu::item_toggle(Game& game) { Fighter& fighter = game.local_get(); - bool isTrinket = tab == TAB_TRINKETS; - if (!is_choosable(game, isTrinket)) + ItemKind kind = (ItemKind)tab; + if (!is_choosable(game, kind)) return; - int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int slots = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); int item = selections[tab]; int equipped = std::find(loadout, loadout + slots, item) - loadout; if (equipped < slots) { - loadout_set(game, isTrinket, equipped, -1); + loadout_set(game, kind, 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); + loadout_set(game, kind, std::min(empty, slots - 1), item); game.audio.play(SOUND_EQUIP); } +// The About tab: the game's name, then a row per credit, each with a faded label and what it credits, and a link (or +// more) pushed to the far side. +void Menu::about_draw(Game& game, Rect area) +{ + struct Credit + { + const char* label; + const char* name; + const char* nameUrl; + const char* sideLabel; + const char* side; + const char* sideUrl; + }; + constexpr Credit CREDITS[] = { + {"Art and Design:", "Shweet", nullptr, "", "shweetz.net", "https://shweetz.net"}, + {"Font:", "Ithaca", nullptr, "", "ggbot.itch.io/ithaca-font", "https://ggbot.itch.io/ithaca-font"}, + {"SFX:", "Sound Ideas", nullptr, "", "sound-ideas.com", "https://sound-ideas.com"}, + {"Source Code:", "git.shweetz.net/shweet/smack-a-brother/", "https://git.shweetz.net/shweet/smack-a-brother/", + "License:", "CC0", nullptr}, + }; + Ui& ui = game.ui; + Text& text = game.text; + float space = text.size_get(" ").x; + // A label (faded), then its text (or link) after it. + auto pair_draw = [&](float x, float y, const char* label, const char* value, const char* url) { + if (*label) + { + ui.label_draw(game, label, {x, y}); + x += text.size_get(label).x + space; + } + if (url) + ui.link_draw(game, {x, y}, value, url); + else + text.draw(game.renderer, value, {.color = ui.tint, .position = {x, y + TEXT_ROW_INSET}}); + }; + + float y = area.y; + ui.separator_draw(game, {area.x, y, area.w, ROW_HEIGHT}, "Smack a Brother"); + y += ROW_HEIGHT; + for (const Credit& credit : CREDITS) + { + pair_draw(area.x, y, credit.label, credit.name, credit.nameUrl); + float sideWidth = + text.size_get(credit.side).x + (*credit.sideLabel ? text.size_get(credit.sideLabel).x + space : 0); + pair_draw(area.x + area.w - sideWidth, y, credit.sideLabel, credit.side, credit.sideUrl); + y += ROW_HEIGHT; + } +} + // 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. +// leaving, with a Ready button beside it while a round counts down. Spectating, it's a button to stop. void Menu::header_draw(Game& game, MenuLayout& layout) { Renderer& renderer = game.renderer; @@ -432,6 +485,16 @@ void Menu::header_draw(Game& game, MenuLayout& layout) if (match.state == MATCH_PRACTICE) return; Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING}; + if (game.session.is_watching(game)) + { + button.w += HEADER_BUTTON_WIDTH; + if (ui.button_draw(game, button, "Stop Spectating")) + { + game.session.watch_send(game, 0); + isOpen = false; + } + return; + } if (match.state == MATCH_COUNTDOWN) { bool isReady = match.isReady[game.local]; @@ -481,29 +544,27 @@ void Menu::items_draw(Game& game, MenuLayout& layout) 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], + .level = game.local_get().item_levels_get((ItemKind)tab)[i], .isHoverable = true, .isLit = i == selection, - .isTrinket = tab == TAB_TRINKETS}); + .kind = (ItemKind)tab}); } Fighter& fighter = game.local_get(); - for (int kind = 0; kind < ITEM_TABS; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == TAB_TRINKETS; - int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int count = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); for (int index = 0; index < count; index++) { - if (!ui.focus_add(loadout, index, slot_rect(isTrinket, index, count)) || loadout[index] < 0) + if (!ui.focus_add(loadout, index, slot_rect(kind, index, count)) || loadout[index] < 0) continue; pinnedSlot = index; pinnedTab = tab; - isPinnedTrinket = isTrinket; - if (ui.isConfirmed && is_choosable(game, isTrinket)) - slot_clear(game, isTrinket, index); + pinnedKind = kind; + if (ui.isConfirmed && is_choosable(game, kind)) + slot_clear(game, kind, index); } } @@ -520,31 +581,28 @@ void Menu::items_draw(Game& game, MenuLayout& layout) // In a match (but not while choosing from this tab), the selection shows at its level in the sandbox, which is // what levels here. bool isMatch = game.sim.match.state != MATCH_PRACTICE; - bool isTrinket = tab == TAB_TRINKETS; - bool isSandboxLevel = isMatch && !is_choosable(game, isTrinket); + ItemKind kind = (ItemKind)tab; + bool isSandboxLevel = isMatch && !is_choosable(game, kind); int item = selections[tab]; - int level = (isSandboxLevel ? sandbox_levels_get(isTrinket) - : isTrinket ? fighter.trinketItemLevels - : fighter.weaponItemLevels)[item]; + int level = (isSandboxLevel ? sandbox_levels_get(kind) : fighter.item_levels_get(kind))[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) + int* pinnedLoadout = fighter.loadout_get(pinnedKind); + if (pinnedSlot >= 0 && pinnedSlot < fighter.slots_get(pinnedKind) && pinnedLoadout[pinnedSlot] >= 0) { - isTrinket = isPinnedTrinket; + kind = pinnedKind; item = pinnedLoadout[pinnedSlot]; - slotLevel = &(isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[pinnedSlot]; + slotLevel = &fighter.slot_levels_get(kind)[pinnedSlot]; level = *slotLevel; } - int shown = isTrinket * WEAPON_COUNT * TRINKET_COUNT + item; + int shown = kind * WEAPON_COUNT * TRINKET_COUNT + item; if (shown != described) description.scroll = 0; described = shown; // Only what can be levelled here previews its next level. bool isLevelable = !isMatch || (isSandboxLevel && !slotLevel); - Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level, isLevelable); + Tooltip tooltip = ui.tooltip_build(game, kind, item, level, isLevelable ? TOOLTIP_NEXT_LEVEL : TOOLTIP_PLAIN); if (slotLevel) tooltip.slot = pinnedSlot; Rect area = layout.description; @@ -571,7 +629,7 @@ void Menu::items_draw(Game& game, MenuLayout& layout) if (!ui.button_draw(game, button, step < 0 ? "-" : "+", step < 0 ? "level down" : "level up")) continue; if (slotLevel) - slot_level_step(game, isPinnedTrinket, pinnedSlot, step); + slot_level_step(game, pinnedKind, pinnedSlot, step); else level_step(game, step, {-1, -1}); } @@ -586,10 +644,15 @@ void Menu::items_draw(Game& game, MenuLayout& layout) void Menu::play_draw(Game& game, MenuLayout& layout) { Ui& ui = game.ui; - // In a match, it's only the game's rules. + // In a match, it's only the game's rules, and loading a saved loadout into the slots being chosen. if (game.sim.match.state != MATCH_PRACTICE) { - ui.game_summary_draw(game, game.sim.match.rules, {layout.body.x, layout.body.y}); + float height = ui.game_summary_draw(game, game.sim.match.rules, {layout.body.x, layout.body.y}); + Tooltip hovered; + game.settings.loadouts_draw( + game, {layout.body.x, layout.body.y + height + PADDING * 2, layout.body.w, WIDGET_HEIGHT}, hovered); + if (*hovered.name) + ui.tooltip_draw(game, hovered); return; } crumbs_draw(game, layout); @@ -598,8 +661,7 @@ void Menu::play_draw(Game& game, MenuLayout& layout) area.h -= CRUMB_HEIGHT + DIVIDER; if (page == PAGE_RULES || page == PAGE_QUEUE) { - bool isQueue = page == PAGE_QUEUE; - rules_draw(game, area, isQueue ? queueRules : game.sim.match.rules, isQueue); + rules_draw(game, area); return; } if (page == PAGE_ONLINE) @@ -668,10 +730,21 @@ static std::string lobby_page_get() #endif } +// A lobby member's colour: as this screen shows their fighter when they're in the same room, or as the lobby last +// heard otherwise (plain until then). +static Color member_color_get(Game& game, int member) +{ + int fighter = game.session.fighter_get(game, member); + int color = game.session.color_get(member); + if (fighter >= 0) + return game.color_get(fighter, TINT_PLAIN); + return color >= 0 ? PALETTE[color] : game.ui.tint; +} + // 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. +// why it failed), and under it where lobbies are hosted. In one: its code (to read out or copy to friends) and leaving, +// queueing a game in its sandbox, and everyone in it with how their connection is doing, then the games being played, +// to spectate. void Menu::online_draw(Game& game, Rect area) { Ui& ui = game.ui; @@ -686,11 +759,13 @@ void Menu::online_draw(Game& game, Rect area) if (net.state != NET_LOBBY) { bool isBusy = net.state == NET_CONNECTING || net.state == NET_JOINING; + ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Lobby"); + y += ROW_HEIGHT; ui.widgets_enable(!isBusy); - if (ui.button_draw(game, {area.x, y, area.w, PLAY_HEIGHT}, "Create Lobby")) + if (ui.button_draw(game, {area.x, y, area.w, PLAY_HEIGHT}, "Create")) net.lobby_create(game); y += PLAY_HEIGHT + PADDING; - ui.label_draw(game, "Lobby Code", {area.x, y}); + ui.label_draw(game, "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) @@ -706,13 +781,17 @@ void Menu::online_draw(Game& game, Rect area) status_draw("Joining...", false); else if (!net.error.empty()) status_draw(net.error.c_str(), true); + + // Where the lobbies are hosted. + ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Online"); + y += ROW_HEIGHT; + ui.hint_set(game, {area.x, y, area.w, WIDGET_HEIGHT}, "The server hosting lobbies.\nEmpty it to reset."); + ui.label_draw(game, "Host", {area.x, y}); + game.settings.host_draw(game, {area.x + LABEL_WIDTH, y, area.w - LABEL_WIDTH, WIDGET_HEIGHT}); 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; + // The lobby's code, big, with buttons to copy it (or a link to it) and to leave under it. 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}}); @@ -751,51 +830,81 @@ void Menu::online_draw(Game& game, Rect area) 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.queue_send(game, {}, false); + session.queue_cancel(game); ui.widgets_enable(true); y += ROW_HEIGHT; - // Everyone in it, in the order they came (the first hosting), in their colours, with their connections. When they - // don't all fit down the page, they go in two columns in the tiny font, names cut short to fit. - ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Players"); - y += ROW_HEIGHT; - int count = net.members.size(); - bool isCompact = y + count * ROW_HEIGHT > area.y + area.h; - Font font = isCompact ? FONT_TINY : FONT_SMALL; - float rowHeight = isCompact ? text.line_height_get(FONT_TINY) + COMPACT_ROW_GAP : ROW_HEIGHT; - float inset = isCompact ? 0 : TEXT_ROW_INSET; - int rows = isCompact ? (count + 1) / 2 : count; - float columnWidth = isCompact ? std::floor((area.w - PADDING) / 2) : area.w; + // Everyone in it and the games being played, scrolling below. Each player is in the order they came (the first + // hosting), in their colour, with their connection and a little of them on the right. Each game is its players + // and its mode, and clicking one from the sandbox spectates it. + Rect list = {area.x, y, area.w, area.y + area.h - y}; + float rowWidth = list.w - SCROLL_WIDTH * 2; float pingWidth = text.size_get(PING_SAMPLE).x + PADDING * 2; - for (int k = 0; k < count; k++) + ui.page_begin(game, lobbyPage, list); + if (ui.row_get(lobbyPage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Players"); + for (NetMember& member : net.members) { - NetMember& member = net.members[k]; + // A row grows to fit what its player wears over its top. + int fighter = game.session.fighter_get(game, member.id); + int worn = fighter >= 0 ? game.fashion_get(fighter) : FASHION_NONE; + float rise = std::ceil(std::max(0.0f, game.sprites.fashion_height_get(worn) - (MINI_PLAYER_ROW - 1))); + if (!ui.row_get(lobbyPage, MINI_PLAYER_ROW + rise, y)) + continue; + y += rise; bool isSelf = member.id == net.selfId; std::string name = session.isActive ? session.name_get(game, member.id) : member.name; const char* tags = member.id == net.hostId ? (isSelf ? " [Host] [You]" : " [Host]") : isSelf ? " [You]" : ""; - float room = columnWidth - (member.ping >= 0 ? pingWidth : 0); + float room = rowWidth - MINI_PLAYER_WIDTH - (member.ping >= 0 ? pingWidth : 0); SDL_snprintf(line, sizeof(line), "- %s%s", name.c_str(), tags); - while (text.size_get(line, font).x > room && !name.empty()) + while (text.size_get(line).x > room && !name.empty()) { name.pop_back(); SDL_snprintf(line, sizeof(line), "- %s...%s", name.c_str(), tags); } - int fighter = session.fighter_get(game, member.id); - Color color = fighter >= 0 && game.sim.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint; - Vec2 corner = {area.x + (k / rows) * (columnWidth + PADDING), y + (k % rows) * rowHeight + inset}; - text.draw(game.renderer, line, {.color = color, .position = corner}, font); + Color color = member_color_get(game, member.id); + Vec2 corner = {area.x, std::round(y + (MINI_PLAYER_ROW - text.line_height_get(FONT_SMALL)) / 2)}; + text.draw(game.renderer, line, {.color = color, .position = corner}); if (member.ping >= 0) - ui.ping_draw(game, {corner.x + text.size_get(line, font).x + PADDING * 2, corner.y}, member.ping, 1); + ui.ping_draw(game, {corner.x + text.size_get(line).x + PADDING * 2, corner.y}, member.ping, 1); + ui.player_draw(game, {area.x + rowWidth - MINI_PLAYER_WIDTH / 2, y + MINI_PLAYER_ROW - 1}, color, 1, worn); } - y += rows * rowHeight; - 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), ""); + if (ui.row_get(lobbyPage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Games"); + bool isSpectatable = session.isJoined && game.sim.match.state == MATCH_PRACTICE; + for (size_t k = 1; k < session.lobbyRooms.size(); k++) + { + SessionRoom& lobbyRoom = session.lobbyRooms[k]; + if (!ui.row_get(lobbyPage, GAME_ROW, y)) + continue; + Rect box = {area.x, y, rowWidth, GAME_ROW - PADDING / 2}; + ui.widgets_enable(isSpectatable); + if (ui.button_draw(game, box, "", &lobbyRoom.room) && isSpectatable) + session.watch_send(game, lobbyRoom.room); + ui.widgets_enable(true); + float x = area.x + PADDING; + float textY = std::round(y + (box.h - text.line_height_get(FONT_SMALL)) / 2); + for (auto [player, color] : lobbyRoom.players) + { + std::string name = session.name_get(game, player); + float nameWidth = text.size_get(name.c_str()).x; + if (x + nameWidth > box.x + box.w - GAME_ROW) + break; + text.draw(game.renderer, name.c_str(), {.color = PALETTE[color], .position = {x, textY}}); + x += nameWidth + PADDING * 2; + } + game.sprites.modes.draw(game.renderer, MODE_NAMES[lobbyRoom.mode], 0, + {.color = ui.tint, .position = {box.x + box.w - box.h / 2, y + box.h / 2}}); + } + if (session.lobbyRooms.size() <= 1 && ui.row_get(lobbyPage, ROW_HEIGHT, y)) + text.draw(game.renderer, "None yet", {.color = ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); + if (game.session.isDesynced && ui.row_get(lobbyPage, ROW_HEIGHT, y)) + text.draw(game.renderer, "Out of sync with the lobby: rejoin to fix it", + {.color = ui.tint, .position = {area.x, y + TEXT_ROW_INSET}}); + else if (game.session.isActive && !game.session.isJoined && ui.row_get(lobbyPage, ROW_HEIGHT, y)) + text.draw(game.renderer, "Joining the lobby's game...", + {.color = ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); + ui.page_end(game, lobbyPage); } // The pages taken to get here, left to right, the current one lit. Clicking an earlier one goes back to it. @@ -833,8 +942,10 @@ void Menu::crumbs_draw(Game& game, MenuLayout& layout) // 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) +void Menu::rules_draw(Game& game, Rect area) { + bool isQueue = page == PAGE_QUEUE; + Rules& rules = isQueue ? queueRules : game.sim.match.rules; Ui& ui = game.ui; Page& rulesPage = pages[TAB_PLAY]; float x = area.x + LABEL_WIDTH; @@ -848,9 +959,10 @@ void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) row.y = y; return isShown; }; - auto radios = [&](const char* label, const char* const* names, int count, int& value) { + auto radios = [&](const char* label, const char* const* names, int count, int& value, const char* hint) { if (!isRow(ROW_HEIGHT)) return; + ui.hint_set(game, row, hint); ui.label_draw(game, label, {area.x, y}); float radioX = x; for (int i = 0; i < count; i++) @@ -859,15 +971,18 @@ void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) radioX += WIDGET_HEIGHT + WIDGET_GAP + game.text.size_get(names[i]).x + RADIO_GAP; } }; - auto number = [&](const char* label, auto& value, NumberRange range) { + auto number = [&](const char* label, auto& value, NumberRange range, const char* hint) { if (!isRow(ROW_HEIGHT)) return; + ui.hint_set(game, row, hint); 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 toggle = [&](const char* label, bool& value, const char* hint) { + if (!isRow(ROW_HEIGHT)) + return; + ui.hint_set(game, row, hint); + ui.toggle_draw(game, {x, y}, label, value); }; auto separator = [&](const char* label) { if (isRow(ROW_HEIGHT)) @@ -875,17 +990,19 @@ void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) }; auto items = [&](const char* title, ItemRules& items) { separator(title); - number("Slots", items.slots, {.min = 1, .max = SLOT_MAX}); + number("Slots", items.slots, {.min = 1, .max = SLOT_MAX}, "How many each\nfighter carries."); int choice = items.isChoice; - radios("Picked By", PICK_NAMES, std::size(PICK_NAMES), choice); + radios("Picked By", PICK_NAMES, std::size(PICK_NAMES), choice, "Dealt at random, or\nchosen by each fighter."); 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); + toggle("Mirror (Everyone Gets the Same)", items.isMirror, "Every fighter gets\nthe same loadout."); + toggle("Duplicates Allowed", items.isDuplicates, "A loadout can hold the\nsame one more than once."); + radios("Picked On", TIMING_NAMES, std::size(TIMING_NAMES), items.timing, + "Picked once, every round,\nor every few rounds."); 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); + number("Period", items.period, {.min = PERIOD_MIN, .max = PERIOD_MAX, .suffix = " Rounds"}, + "Rounds between picks."); + number("Start Level", items.startLevel, {.min = 1, .max = LEVEL_COUNT}, "The level they start at."); + toggle("Level Up Each Round", items.isLevelUp, "They gain a level\nevery round."); }; if (isRow(ui.game_summary_size(game, rules).y + PADDING)) @@ -899,16 +1016,19 @@ void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) if (!settings.isRulesOpen) { ui.page_end(game, rulesPage); - play_button_draw(game, footer, isQueue); + play_button_draw(game, footer); 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")) + bool isDefaultShown = isRow(ROW_HEIGHT); + if (isDefaultShown) + ui.hint_set(game, {x, y, NUMBER_WIDTH, WIDGET_HEIGHT}, "Resets the rules."); + if (isDefaultShown && 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}); + number("Count", rules.players, {.min = PLAYERS_MIN, .max = PLAYERS_MAX}, "How many fighters play."); if (!isQueue) players_join(game); for (int i = 0; i < rules.players && !isQueue; i++) @@ -930,27 +1050,32 @@ void Menu::rules_draw(Game& game, Rect area, Rules& rules, bool isQueue) {.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); + toggle("Fill Empty Slots with Bots", rules.isBotFill, "Bots take the places\nno player fills."); 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}); + number("Best Of", rules.bestOf, {.min = 1, .max = BEST_OF_MAX, .step = 2}, + "Rounds in the match.\nTake over half to win."); 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("Timer", rules.timer, {.min = 0, .max = TIMER_MAX, .zero = "Infinite", .suffix = " Min"}, + "Each round's time limit."); + number("Base Health", rules.health, {.min = BASE_HEALTH_MIN, .max = BASE_HEALTH_MAX, .step = BASE_HEALTH_STEP}, + "Each fighter's health\nin the first round."); number("Round Health Multiplier", rules.healthMultiplier, - {.min = 1, .max = MULTIPLIER_MAX, .step = MULTIPLIER_STEP, .suffix = "x"}); + {.min = 1, .max = MULTIPLIER_MAX, .step = MULTIPLIER_STEP, .suffix = "x"}, + "Health is multiplied by\nthis each round after."); ui.page_end(game, rulesPage); - play_button_draw(game, footer, isQueue); + play_button_draw(game, footer); } // 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) +void Menu::play_button_draw(Game& game, Rect button) { + bool isQueue = page == PAGE_QUEUE; Ui& ui = game.ui; if (isQueue) { @@ -1045,7 +1170,7 @@ void Menu::online_open() void Menu::choice_open(Game& game) { isOpen = true; - tab = game.sim.match.isWeaponPick ? TAB_WEAPONS : TAB_TRINKETS; + tab = game.sim.match.isPicks[ITEM_WEAPON] ? TAB_WEAPONS : TAB_TRINKETS; targets_reset(game); } @@ -1053,22 +1178,21 @@ void Menu::choice_open(Game& game) void Menu::targets_reset(Game& game) { Fighter& fighter = game.local_get(); - for (int kind = 0; kind < ITEM_TABS; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == TAB_TRINKETS; - int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int slots = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); 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) +int Menu::target_get(Game& game, ItemKind kind) { 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) + int slots = fighter.slots_get(kind); + if (!is_bars_open() || tab != kind || !is_choosable(game, kind) || slots == 0) return -1; return std::min(targets[tab], slots - 1); } @@ -1078,10 +1202,10 @@ int Menu::target_get(Game& game, bool isTrinket) 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); + ItemKind kind = (ItemKind)tab; + int slots = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); + int target = target_get(game, kind); if (target < 0) return; bool isClearing = loadout[target] == selections[tab]; @@ -1114,32 +1238,33 @@ int Menu::tabs_get(Game& game, int* tabs) tabs[count++] = i; return count; } - for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS}) + for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS, TAB_ABOUT}) tabs[count++] = tab; 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) +bool Menu::is_choosable(Game& game, ItemKind kind) { Match& match = game.sim.match; if (match.state == MATCH_PRACTICE) return true; - return match.state == MATCH_COUNTDOWN && (isTrinket ? match.isTrinketPick : match.isWeaponPick); + return match.is_picking(kind); } // Where a hotbar slot sits: closed, the weapon bar is top left and the trinket bar bottom left, each as large as fits // its column. On an item tab, both sit under the menu either side of its middle, full size. -Rect Menu::slot_rect(bool isTrinket, int index, int count) +Rect Menu::slot_rect(ItemKind kind, int index, int count) { IconSize size = slot_size_get(count); float slot = SLOT_SIZES[size]; float step = slot + SLOT_GAPS[size]; - Vec2 position = {HOTBAR_MARGIN + index * step, isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN}; + Vec2 position = {HOTBAR_MARGIN + index * step, + kind == ITEM_TRINKET ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : 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, + position = {kind == ITEM_TRINKET ? 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, slot}; } @@ -1191,7 +1316,7 @@ Rect Menu::box_rect(int index) int Menu::item_count() { - return tab == TAB_TRINKETS ? TRINKET_COUNT : WEAPON_COUNT; + return item_count_get((ItemKind)tab); } int Menu::row_count() @@ -1204,25 +1329,25 @@ int Menu::row_count() void Menu::slot_assign(Game& game, int slot) { Fighter& fighter = game.local_get(); - int* loadout = tab == TAB_TRINKETS ? fighter.trinketLoadout : fighter.weaponLoadout; + int* loadout = fighter.loadout_get((ItemKind)tab); int item = selections[tab]; bool isClearing = loadout[slot] == item; - loadout_set(game, tab == TAB_TRINKETS, slot, isClearing ? -1 : item); + loadout_set(game, (ItemKind)tab, slot, isClearing ? -1 : item); game.audio.play(isClearing ? SOUND_UNEQUIP : SOUND_EQUIP); } -void Menu::slot_clear(Game& game, bool isTrinket, int slot) +void Menu::slot_clear(Game& game, ItemKind kind, int slot) { Fighter& fighter = game.local_get(); - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int* loadout = fighter.loadout_get(kind); if (loadout[slot] < 0) return; - loadout_set(game, isTrinket, slot, -1); + loadout_set(game, kind, 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) +void Menu::loadout_set(Game& game, ItemKind kind, int slot, int item) { - game.input.edit_send(game.devices[game.local], pick_encode(isTrinket, slot, item)); + game.input.edit_send(game.devices[game.local], pick_encode(kind, slot, item)); } diff --git a/src/menu.h b/src/menu.h index 9e44374..590d2a7 100644 --- a/src/menu.h +++ b/src/menu.h @@ -16,13 +16,15 @@ constexpr float BAR_SPACING = 12; constexpr float BAR_Y = MENU_TOP + HEADER_HEIGHT + BODY_HEIGHT + BAR_SPACING; constexpr float HINT_GAP = 3; +// The item tabs come first, in ItemKind's order, so an item tab is its kind. enum MenuTab { - TAB_WEAPONS, - TAB_TRINKETS, + TAB_WEAPONS = ITEM_WEAPON, + TAB_TRINKETS = ITEM_TRINKET, TAB_PLAY, - TAB_CUSTOMIZE, + TAB_PROFILE, TAB_SETTINGS, + TAB_ABOUT, TAB_COUNT }; @@ -35,7 +37,7 @@ enum PlayPage PAGE_QUEUE }; -constexpr int ITEM_TABS = TAB_PLAY; +constexpr int ITEM_TABS = ITEM_KIND_COUNT; // Where everything in the open menu sits. Rows is how many grid rows fit on screen. struct MenuLayout @@ -52,7 +54,7 @@ struct MenuLayout }; // 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 +// set up and start a game from Play, change your name in Profile, 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 @@ -73,13 +75,14 @@ public: void targets_reset(Game& game); void target_fill(Game& game); bool is_bars_open(); - Rect slot_rect(bool isTrinket, int index, int count); + Rect slot_rect(ItemKind kind, int index, int count); IconSize slot_size_get(int count); - bool is_choosable(Game& game, bool isTrinket); - int target_get(Game& game, bool isTrinket); + bool is_choosable(Game& game, ItemKind kind); + int target_get(Game& game, ItemKind kind); private: Page description; + Page lobbyPage; 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. @@ -87,7 +90,7 @@ private: // Each item tab's target slot: where an item clicked a second time goes. int targets[ITEM_TABS] = {}; int pinnedTab = -1; - bool isPinnedTrinket = false; + ItemKind pinnedKind = ITEM_WEAPON; bool wasPicking = false; bool wasReady = false; // The sandbox's item levels, kept while in it, so a match can level them for when this screen is back. @@ -113,21 +116,22 @@ private: 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 slot_clear(Game& game, ItemKind kind, int slot); + void loadout_set(Game& game, ItemKind kind, int slot, int item); void item_toggle(Game& game); void level_step(Game& game, int step, Vec2 mouse); void sandbox_levels_update(Game& game); - int* sandbox_levels_get(bool isTrinket); - void slot_level_step(Game& game, bool isTrinket, int slot, int step); + int* sandbox_levels_get(ItemKind kind); + void slot_level_step(Game& game, ItemKind kind, 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 about_draw(Game& game, Rect area); + void rules_draw(Game& game, Rect area); void online_draw(Game& game, Rect area); - void play_button_draw(Game& game, Rect button, bool isQueue); + void play_button_draw(Game& game, Rect button); 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 index e83e368..653ad53 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -42,9 +42,8 @@ struct Net::Impl struct Delayed { Uint64 due; - bool isReliable; + Delivery delivery; std::string bytes; - bool isText = false; }; std::vector delayed; Uint64 lag = 0; @@ -244,6 +243,7 @@ void Net::message_handle(const std::string& text) member.id = message["id"].get(number) ? -1 : (int)number; member.name = message["name"].get(field) ? "" : std::string(field); members.push_back(member); + notices.push_back(member.name + " joined the lobby."); return; } if (type == "kicked") @@ -264,6 +264,9 @@ void Net::message_handle(const std::string& text) 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; + NetMember* member = member_get(id); + if (member && !isKicked) + notices.push_back(member->name + " left the lobby."); std::erase_if(members, [&](NetMember& member) { return member.id == id; }); return; } @@ -342,28 +345,32 @@ void Net::signal_handle(const std::string& text) } // Sends the server a game message, held back or dropped first by the network simulator if it's on. -void Net::send(const std::string& bytes, bool isReliable) +void Net::send(const std::string& bytes, Delivery delivery) { if (impl->lag > 0 || impl->loss > 0) { impl->lossSeed = impl->lossSeed * 1103515245 + 12345; - if (!isReliable && (int)(impl->lossSeed >> 16) % 100 < impl->loss) + if (delivery == DELIVERY_LOSSY && (int)(impl->lossSeed >> 16) % 100 < impl->loss) return; if (impl->lag > 0) { - impl->delayed.push_back({SDL_GetTicks() + impl->lag, isReliable, bytes}); + impl->delayed.push_back({SDL_GetTicks() + impl->lag, delivery, bytes}); return; } } - send_now(bytes, isReliable); + send_now(bytes, delivery); } -// Over the data channels once they're open, or the socket until then. -void Net::send_now(const std::string& bytes, bool isReliable) +// Over the data channels once they're open (text, like pings, over the reliable one), or the socket until then. +void Net::send_now(const std::string& bytes, Delivery delivery) { auto data = reinterpret_cast(bytes.data()); - if (is_linked()) - (isReliable ? impl->link.channel : impl->link.inputs)->send(data, bytes.size()); + if (delivery == DELIVERY_TEXT && is_linked()) + impl->link.channel->send(bytes); + else if (delivery == DELIVERY_TEXT) + return; + else if (is_linked()) + (delivery == DELIVERY_RELIABLE ? impl->link.channel : impl->link.inputs)->send(data, bytes.size()); else if (impl->socket && impl->socket->isOpen() && state == NET_LOBBY) impl->socket->send(rtc::binary(data, data + bytes.size())); } @@ -373,9 +380,9 @@ void Net::ping_send() { std::string text = "ping:" + std::to_string(SDL_GetTicksNS()); if (impl->lag > 0) - impl->delayed.push_back({SDL_GetTicks() + impl->lag, true, text, true}); - else if (is_linked()) - impl->link.channel->send(text); + impl->delayed.push_back({SDL_GetTicks() + impl->lag, DELIVERY_TEXT, text}); + else + send_now(text, DELIVERY_TEXT); } // Asks the server to put a member out of the lobby (it only does so for the host). @@ -409,10 +416,7 @@ void Net::update() { Impl::Delayed due = impl->delayed.front(); impl->delayed.erase(impl->delayed.begin()); - if (due.isText && is_linked()) - impl->link.channel->send(due.bytes); - else if (!due.isText) - send_now(due.bytes, due.isReliable); + send_now(due.bytes, due.delivery); } if (state == NET_LOBBY && now >= impl->nextPing) { diff --git a/src/net.h b/src/net.h index c430830..d59e905 100644 --- a/src/net.h +++ b/src/net.h @@ -43,7 +43,7 @@ public: void update(); void shutdown(); void heartbeat(); - void send(const std::string& bytes, bool isReliable); + void send(const std::string& bytes, Delivery delivery); void kick_send(int member, const std::string& reason); bool is_linked(); @@ -65,7 +65,7 @@ private: std::unique_ptr impl; void request_send(Game& game, const std::string& request); - void send_now(const std::string& bytes, bool isReliable); + void send_now(const std::string& bytes, Delivery delivery); void ping_send(); void message_handle(const std::string& text); void link_open(); diff --git a/src/protocol.h b/src/protocol.h index a012f5e..0cc4209 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 4; +constexpr int PROTOCOL_VERSION = 5; constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_CODE_LENGTH = 4; // Letters only, without I and O, so codes are easy to read out and type. @@ -50,6 +50,8 @@ constexpr int SERVER_URL_LENGTH = 128; // PACKET_TYPING Uint8 isTyping // PACKET_RENAME Uint16 length, text // PACKET_PING Uint16 milliseconds this player's round trip to the server +// PACKET_WATCH room spectate one of the lobby's games (from its sandbox), +// or 0 to go back to the sandbox // // Server to game: // PACKET_STATE room, tick, state the game this player is in now, as a tick starts @@ -59,6 +61,8 @@ constexpr int SERVER_URL_LENGTH = 128; // once the events before them are in, and how early // (in ticks) this player's last controls came // PACKET_CHECKSUM room, tick, Uint32 the room's state as that tick starts +// PACKET_ROOMS Uint8 count, then for each: room, Uint8 mode, Uint8 players, then (member, Uint8 colour)* +// who's in each of the lobby's rooms, the sandbox first // PACKET_CHAT from, Uint16 length, text and the same for typing, renames and pings enum PacketType : unsigned char { @@ -72,7 +76,18 @@ enum PacketType : unsigned char PACKET_EVENT, PACKET_FRAMES, PACKET_CHECKSUM, - PACKET_PING + PACKET_PING, + PACKET_ROOMS, + PACKET_WATCH +}; + +// How a message goes: lobby text over the socket, or game bytes over the data channels, either reliably and in order +// or lossily (for frames and inputs, where a newer one soon makes up for a lost one). +enum Delivery +{ + DELIVERY_TEXT, + DELIVERY_RELIABLE, + DELIVERY_LOSSY }; // Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). diff --git a/src/renderer.cpp b/src/renderer.cpp index b27dbe2..857550a 100644 --- a/src/renderer.cpp +++ b/src/renderer.cpp @@ -9,9 +9,12 @@ constexpr int ATLAS_PADDING = 2; constexpr int ATLAS_SIZE = 1024; +// Where alpha sits in an atlas pixel (RGBA bytes, read as a little-endian word). +constexpr int ALPHA_SHIFT = 24; constexpr int LOG_LENGTH = 1024; constexpr int MAX_VERTICES = 6 * 16384; constexpr int QUAD_CORNERS = 4; +constexpr int TRIANGLE_CORNERS = 3; constexpr int WHITE_SIZE = 4; constexpr int QUAD_ORDER[] = {0, 1, 2, 0, 2, 3}; constexpr float COLOR_MAX = 255.0f; @@ -308,6 +311,16 @@ Rect Renderer::image_load(const std::string& relative) return image; } +// How far down an atlas region its art starts (its first row with anything showing), while images are still loading. +float Renderer::top_get(Rect region) +{ + for (int y = 0; y < region.h && !atlasPixels.empty(); y++) + for (int x = 0; x < region.w; x++) + if (atlasPixels[(int)(region.y + y) * ATLAS_SIZE + (int)region.x + x] >> ALPHA_SHIFT) + return y; + return 0; +} + void Renderer::draw(Draw draw) { quad_draw(draw.region, affine_get(draw.position, draw.angle, draw.scale, draw.origin), draw.color, draw.flash); @@ -344,6 +357,20 @@ void Renderer::rect_draw(Rect rect, Color color) {.color = color, .region = white, .position = {rect.x, rect.y}, .scale = {rect.w / white.w, rect.h / white.h}}); } +// A flat triangle through three corners, each with its own colour blended across it. +void Renderer::triangle_draw(const Vec2* corners, const Color* colors) +{ + if (vertices.size() + TRIANGLE_CORNERS > MAX_VERTICES) + flush(); + float u = (white.x + white.w / 2) / ATLAS_SIZE; + float v = (white.y + white.h / 2) / ATLAS_SIZE; + for (int i = 0; i < TRIANGLE_CORNERS; i++) + { + Vec2 corner = corners[i] - camera; + vertices.push_back({corner.x, corner.y, u, v, color_pack(colors[i]), 0}); + } +} + void Renderer::line_draw(Vec2 from, Vec2 to, float width, Color color) { Vec2 delta = to - from; diff --git a/src/renderer.h b/src/renderer.h index 65872d9..5fb4b27 100644 --- a/src/renderer.h +++ b/src/renderer.h @@ -57,10 +57,12 @@ public: Rect atlas_add(uint32_t* pixels, int width, int height); void atlas_upload(); Rect image_load(const std::string& relative); + float top_get(Rect region); 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 triangle_draw(const Vec2* corners, const Color* colors); void circle_draw(Vec2 center, float radius, Color color); void ring_draw(Vec2 center, float radius, Color color); void layer_begin(); diff --git a/src/scene.cpp b/src/scene.cpp index 3690ad0..58683a0 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -1,4 +1,5 @@ #include "scene.h" +#include #include "game.h" #include #include @@ -7,6 +8,8 @@ constexpr int BLINK_MILLISECONDS = 80; +// How long a claimed shot takes to turn from its old owner's colour to its new one's. +constexpr float CLAIM_BLEND = 0.35f; constexpr float TRAJECTORY_ALPHA = 0.12f; constexpr float TRAJECTORY_TAPER = 0.25f; @@ -37,7 +40,35 @@ constexpr float CHAIN_SPACING = 8; // How hard a charging weapon shakes against an attack filled in as far. constexpr float CHARGE_SHAKE = 0.5f; constexpr float COLLISION_ALPHA = 0.25f; +constexpr float CONE_ALPHA = 0.35f; +constexpr float CONE_FILL_ALPHA = 0.3f; +// The air a Vacuum sucks in: how many motes stream along a cone at once, how long each takes from the far end to the +// muzzle, how much of that it spends fading in and out, and their size. +constexpr int SUCK_MOTES = 14; +constexpr float SUCK_TRAVEL = 0.4f; +constexpr float SUCK_FADE_IN = 0.25f; +constexpr float SUCK_FADE_OUT = 0.2f; +constexpr float SUCK_MOTE_SIZE = 2; +// How seeds for motes of air are spread and mixed (see mote_share_get). +constexpr Uint32 MOTE_KEYS = 16; +constexpr Uint32 MOTE_MIX_A = 0x7feb352d; +constexpr Uint32 MOTE_MIX_B = 0x846ca68b; +// How many motes an airblast blows out, and the least share of the cone's reach one gets to. +constexpr int GUST_MOTES = 20; +constexpr float GUST_REACH_MIN = 0.35f; +// How late (as a share of the blast's life) a mote may set off. +constexpr float GUST_STAGGER = 0.35f; +// How much a running Vacuum shakes in the hand at full spin. +constexpr float WHIR_SHAKE = 0.6f; +// How bright a weapon (and its crosshair) is just after firing, brightening back as it cools down. +constexpr float COOLDOWN_DARK = 0.35f; +// A fighter's whir voice when its Vacuum isn't running, or is running without a voice to play on. +constexpr int WHIR_NONE = -1; +constexpr int WHIR_SILENT = -2; +constexpr int CONE_ARC_STEPS = 8; constexpr float EXPLOSION_FLASH = 0.05f; +// How far apart a double donk's blast blinks. +constexpr float DONK_BLINK = 0.1f; constexpr float EXPLOSION_GROW = 4; constexpr float EXPLOSION_OUTLINE = 2; constexpr float FALLOFF_CURVE = 1.5f; @@ -53,8 +84,6 @@ constexpr float GRAPPLE_HANDLE = 7; constexpr float HELD_ICON_SCALE = 0.6f; constexpr float KICK_DECAY = 0.82f; constexpr float KICK_SWEEP = 0.1f; -constexpr float MINE_BLINK = 0.6f; -constexpr float MINE_BLINK_ON = 0.1f; constexpr float PIP_GAP = 3; constexpr float PIP_SIZE = 2; constexpr float PIP_SPIN = 8; @@ -118,7 +147,12 @@ static Color color_dim(Color color, float strength) // 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); + // A claimed shot turns from its old owner's colour to its new one's. + Color color = game.color_get(attack.owner); + if (attack.ownerPrevious >= 0) + color = color_mix(game.color_get(attack.ownerPrevious), color, + std::min(1.0f, (attack.time - attack.claimTime) / CLAIM_BLEND)); + return color_shade(color, attack.isSecondary ? SECONDARY_SHADE : 1); } static Color tint_get(Game& game, Attack& attack) @@ -135,8 +169,8 @@ struct Stroke 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. +// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. 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); @@ -144,7 +178,8 @@ static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) Vec2 shake = jitter_get(fill_get(base)); std::vector points; std::vector distances; - float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0; + float distance = 0; + float reach = falloff_reach_get(attack); for (size_t i = 0; i + 1 < stroke.points.size(); i++) { Vec2 from = stroke.points[i]; @@ -169,7 +204,7 @@ static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) { for (size_t i = 0; i + 1 < points.size(); i++) { - float strength = base * falloff_at(distances[i]); + float strength = base * falloff_at(distances[i] / reach); if (layer == 0) game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); else @@ -196,7 +231,28 @@ static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color colo } } -// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even. +// Fills every pixel in bounds (left, top, right, bottom) that isInside says is in a shape, a row of runs at a time, so +// where parts of it overlap stays even. +template static void shape_fill(Renderer& renderer, Rect bounds, Inside isInside, Color color) +{ + 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; + } + } + } +} + +// Fills every pixel inside any of the capsules. static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color) { Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY}; @@ -219,23 +275,40 @@ static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color } return false; }; - for (float y = std::floor(bounds.y); y <= bounds.h; y++) + shape_fill(renderer, bounds, isInside, color); +} + +// A cone from apex toward tip, cone radians to either side, filled as a fan fading from color at the apex to the share +// of it the cone's strength keeps at its far end (see cone_strength_get). +static void cone_fill_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color) +{ + float angle = vec2_angle(tip - apex); + float range = vec2_length(tip - apex); + Color far = color_fade(color, VACUUM_EDGE); + Color colors[] = {color, far, far}; + for (int i = 0; i < CONE_ARC_STEPS; i++) { - 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; - } - } + Vec2 corners[] = {apex, apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range), + apex + vec2_polar(angle + cone * (2.0f * (i + 1) / CONE_ARC_STEPS - 1), range)}; + renderer.triangle_draw(corners, colors); } } +// A cone from apex toward tip, cone radians to either side, as its two edges and the arc across its far end. +static void cone_outline_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color) +{ + float angle = vec2_angle(tip - apex); + float range = vec2_length(tip - apex); + Vec2 previous = apex; + for (int i = 0; i <= CONE_ARC_STEPS; i++) + { + Vec2 point = apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range); + renderer.line_draw(previous, point, 1, color); + previous = point; + } + renderer.line_draw(previous, apex, 1, color); +} + // 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. static float kick_get(Fighter& fighter) @@ -251,7 +324,7 @@ static float kick_get(Fighter& fighter) // How much of a weapon's cooldown is left, from 1 just fired to 0 ready. static float cooldown_left_get(Fighter& fighter, int key) { - float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); + float total = fighter.level_get(key).cooldown / fighter.stat_get(STAT_ATTACK_RATE); return total > 0 ? std::clamp(fighter.cooldowns[key] / total, 0.0f, 1.0f) : 0; } @@ -281,8 +354,45 @@ float Scene::charge_fill_get(Fighter& fighter) // got, and from fully charged as the weapon is used, easing to still as its cooldown runs out. Vec2 Scene::weapon_shake_get(Fighter& fighter, int key) { + // A running continuous weapon shakes as hard as it's spun up. float left = key < 0 ? 0 : cooldown_left_get(fighter, key); - return jitter_get(std::max(fighter.charge, left * left) * CHARGE_SHAKE); + Attack* running = key < 0 ? nullptr : game.sim.weapons.continuous_get(fighter, key, 0); + float spin = running && !running->isReturning ? running->length * WHIR_SHAKE : 0; + return jitter_get(std::max({fighter.charge, left * left, spin}) * CHARGE_SHAKE); +} + +// A weapon's colour (and its crosshair's) through its cooldown: darkened just after it fires (keeping its hue), back to +// color as it cools. +// One cooling too quickly to see doesn't fade, so rapid fire doesn't flicker. +Color Scene::cooldown_tint_get(Fighter& fighter, int key, Color color) +{ + if (key < 0 || fighter.level_get(key).cooldown / fighter.stat_get(STAT_ATTACK_RATE) < COOLDOWN_VISIBLE) + return color; + return color_shade(color, 1 - cooldown_left_get(fighter, key) * (1 - COOLDOWN_DARK)); +} + +// Each fighter's Vacuum whirs while it runs: spinning up into a loop as it starts, winding down as it's let go. It +// follows the game's state each frame rather than cues, so replays can't leave a whir going. +void Scene::whirs_update() +{ + for (int i = 0; i < MAX_FIGHTERS; i++) + { + bool isRunning = false; + for (Attack& attack : game.sim.weapons.attacks) + isRunning |= i < game.sim.fighterCount && attack.owner == i && !attack.isDead && !attack.isReturning && + attack.def()->kind == ATTACK_VACUUM; + if (isRunning && whirVoices[i] == WHIR_NONE) + { + game.audio.play(SOUND_VACUUM_START); + whirVoices[i] = std::max(WHIR_SILENT, game.audio.loop_play(SOUND_VACUUM)); + } + if (!isRunning && whirVoices[i] != WHIR_NONE) + { + game.audio.stop(whirVoices[i]); + whirVoices[i] = WHIR_NONE; + game.audio.play(SOUND_VACUUM_STOP); + } + } } static bool is_pair(const char* const (&pair)[2], const char* animation) @@ -293,6 +403,7 @@ static bool is_pair(const char* const (&pair)[2], const char* animation) Scene::Scene(Game& game) : game(game) { std::fill(std::begin(chargeVoices), std::end(chargeVoices), -1); + std::fill(std::begin(whirVoices), std::end(whirVoices), WHIR_NONE); } void Scene::sound_play(SoundId sound) @@ -337,22 +448,16 @@ void Scene::flash_spawn(Vec2 position, float angle, float size, int owner) game.effects.flash_spawn(position, angle, size, game.color_get(owner)); } -void Scene::ring_spawn(Vec2 position, float radius, int owner) -{ - if (!isMuted) - game.effects.ring_spawn(position, radius, game.color_get(owner)); -} - void Scene::fighter_afterimage_spawn(Fighter& fighter) { if (!isMuted) - game.effects.afterimage_spawn(fighter_pose_get(fighter)); + game.effects.afterimage_spawn(fighter_pose_get(fighter), game.fashion_get(fighter.index)); } void Scene::attack_afterimage_spawn(Attack& attack) { if (!isMuted) - game.effects.afterimage_spawn(attack_pose(attack)); + game.effects.afterimage_spawn(attack_pose(attack), FASHION_NONE); } void Scene::fighter_aura_spawn(Fighter& fighter) @@ -381,16 +486,16 @@ void Scene::number_spawn(Damage& damage) } // A level going up or down chimes, and outside the menu pops up over this screen's fighter. -void Scene::level_step(bool isUp) +void Scene::level_step(int step) { if (isMuted) return; - game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN); + game.audio.play(step > 0 ? 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 ? game.ui.tint : game.ui.shade); + Vec2 head = {fighter.center_get().x, top_get(fighter)}; + game.effects.popup_spawn(head, step > 0 ? "Level Up" : "Level Down", step > 0 ? game.ui.tint : game.ui.shade); } // The menu closes for whoever plays a starting match on this screen (online, a queued game starting doesn't close a @@ -422,6 +527,7 @@ bool Scene::is_flashing(Fighter& fighter) // Each tick, every fighter's animator plays whatever its fighter is doing. void Scene::update() { + whirs_update(); for (int i = 0; i < game.sim.fighterCount; i++) animator_update(game.sim.fighters[i], game.sprites.fighters[i]); @@ -433,7 +539,7 @@ void Scene::update() Tomato& tomato = match.tomatoes[tomatoesSeen % TOMATO_MAX]; if (tomato.serial != tomatoesSeen || tomato.age > TOMATO_FRESH) continue; - game.effects.burst(TOMATO_BURST, tomato.position, -PI / 2, game.color_get(tomato.owner, true)); + game.effects.burst(TOMATO_BURST, tomato.position, -PI / 2, game.color_get(tomato.owner, TINT_PLAIN)); game.audio.play(SOUND_SPLAT); } } @@ -446,8 +552,8 @@ void Scene::tomatoes_draw() { if (tomato.owner < 0 || tomato.age >= TOMATO_LIFE) continue; - Color color = - color_fade(game.color_get(tomato.owner, true), std::min(1.0f, (TOMATO_LIFE - tomato.age) / TOMATO_FADE)); + Color color = color_fade(game.color_get(tomato.owner, TINT_PLAIN), + std::min(1.0f, (TOMATO_LIFE - tomato.age) / TOMATO_FADE)); game.renderer.circle_draw(tomato.position, TOMATO_CORE, color); Rng look = {.state = (Uint32)tomato.serial * TOMATO_LOOK_STEP + 1}; for (int i = 1; i < TOMATO_BLOBS; i++) @@ -483,6 +589,12 @@ void Scene::paths_draw(Fighter& fighter) for (Path& path : game.sim.weapons.paths_get(game.sim, fighter)) { int last = path.steps.size() - 1; + if (path.cone > 0) + { + cone_outline_draw(game.renderer, path.steps.front(), path.steps.back(), path.cone, + color_fade(base, TRAJECTORY_ALPHA)); + continue; + } for (int i = 0; i < last; i++) { float fade = std::clamp((last - i) / (last * TRAJECTORY_TAPER), 0.0f, 1.0f); @@ -640,6 +752,7 @@ void Scene::fighter_draw(Fighter& fighter) pose.placement.flash = std::max(pose.placement.flash, fill); offhand_draw(fighter); pose.draw(renderer); + game.sprites.fashion_draw(renderer, pose, game.fashion_get(fighter.index)); held_draw(fighter); renderer.camera = camera; @@ -721,6 +834,15 @@ Pose Scene::fighter_pose_get(Fighter& fighter) return animator.pose_get(draw); } +// The top of a fighter: its box's, or higher where what it wears can reach over it (at most, so it holds still as the +// fighter animates). Whatever shows over a fighter (its bars and name, chat, popups) sits above this. +float Scene::top_get(Fighter& fighter) +{ + Rect box = fighter.body.box; + float reach = game.sprites.fashion_height_get(game.fashion_get(fighter.index)) * fighter.scale; + return std::min(box.y, box.y + box.h - reach); +} + void Scene::attacks_draw() { Renderer& renderer = game.renderer; @@ -750,7 +872,7 @@ void Scene::attacks_draw() .flash = fill_get(strength_get(attack, attack.tip))}); } - float progress = attack.time / attack.duration; + float progress = attack.progress_get(); if (kind == ATTACK_BEAM) { float width = (1 - progress) * attack.scale; @@ -761,14 +883,14 @@ void Scene::attacks_draw() // 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 fade = attack.is_charged() ? progress : std::max(0.0f, (progress - PATH_OUT) / (1 - PATH_OUT)); float width = (1 - fade) * attack.scale; std::vector points = - attack.isCharged ? std::vector{attack.position, attack.tip} : lash_get(attack); + attack.is_charged() ? 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}); + {points, WHIP_OUTER * width, WHIP_INNER * width, attack.is_charged() ? 0 : WHIP_JITTER}); game.sprites.effects.draw(renderer, "Whip Tip", 0, {.color = color_fade(tint, 1 - fade), .position = end + shake, @@ -780,8 +902,21 @@ void Scene::attacks_draw() // 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) + if (attack.def()->isBursting && !attack.def()->isInstant) renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA)); + // A Vacuum's cone is outlined: steady while it sucks, fading as an airblast passes. + // Its fill fades toward the far end, as its strength does. + if (is_cone(*attack.def())) + { + Vec2 tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack)); + float alpha = kind == ATTACK_GUST ? 1 - progress : 1; + cone_fill_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_FILL_ALPHA)); + if (kind == ATTACK_VACUUM) + suck_draw(attack, tint); + else + gust_draw(attack, tint); + cone_outline_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_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; @@ -812,24 +947,89 @@ void Scene::magic_draw() } } -// Each link is a jagged bolt whose kinks move every frame so it crackles. +// A share from 0 to 1 that a mote of air draws from a seed, the same every frame (a bit-mixing hash, so neighbouring +// seeds give unrelated shares). +static float mote_share_get(Uint32 seed) +{ + seed ^= seed >> 16; + seed *= MOTE_MIX_A; + seed ^= seed >> 15; + seed *= MOTE_MIX_B; + seed ^= seed >> 16; + return (seed >> 8) / (float)(1u << 24); +} + +// The air a Vacuum sucks in, drawn from its cone rather than spawned, so it always fits it as it swings about: motes +// set off from the far end, each along its own line in the cone, fade in, stream to the muzzle and fade out there, +// one after another on a steady clock. While sucks overlap, only the newest of a hand's draws them. +void Scene::suck_draw(Attack& attack, Color color) +{ + float clock = SDL_GetTicks() / 1000.0f / SUCK_TRAVEL; + for (int i = 0; i < SUCK_MOTES; i++) + { + float phase = clock + (float)i / SUCK_MOTES; + float progress = phase - std::floor(phase); + Uint32 seed = ((Uint32)std::floor(phase) * SUCK_MOTES + i) * MOTE_KEYS + attack.owner; + float side = mote_share_get(seed) * 2 - 1; + float fade = std::min({1.0f, progress / SUCK_FADE_IN, (1 - progress) / SUCK_FADE_OUT}); + Vec2 mote = + attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * (1 - progress)); + Particle particle = {.color = color, .life = fade, .lifeMax = 1, .size = SUCK_MOTE_SIZE}; + particle_draw(game.renderer, particle, mote); + } +} + +// The air a Vacuum's airblast blows out, drawn from its cone like the suck's: motes, each along its own line in the +// cone, setting off a little apart and reaching their own share of the way to its far end, sweep out from the muzzle +// over the blast's life, fading as they go. +void Scene::gust_draw(Attack& attack, Color color) +{ + float progress = std::min(1.0f, attack.progress_get()); + Uint32 blast = std::bit_cast(attack.aimAngle) ^ attack.owner; + for (int i = 0; i < GUST_MOTES; i++) + { + Uint32 seed = blast + i * MOTE_KEYS; + float side = mote_share_get(seed) * 2 - 1; + float reach = GUST_REACH_MIN + (1 - GUST_REACH_MIN) * mote_share_get(seed + 1); + float delay = GUST_STAGGER * mote_share_get(seed + 2); + float own = std::clamp((progress - delay) / (1 - delay), 0.0f, 1.0f); + float sweep = 1 - (1 - own) * (1 - own); + Vec2 mote = + attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * reach * sweep); + Particle particle = {.color = color, .life = 1 - own, .lifeMax = 1, .size = SUCK_MOTE_SIZE}; + particle_draw(game.renderer, particle, mote); + } +} + +// Each link is a jagged bolt whose kinks move every frame so it crackles, one bolt width thicker per coil. void Scene::tesla_draw() { for (int i = 0; i < game.sim.fighterCount; i++) - for (Capsule& link : game.sim.weapons.chain_get(game.sim, game.sim.fighters[i])) - bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i)); + { + Fighter& fighter = game.sim.fighters[i]; + float width = BOLT_WIDTH * fighter.trinket_count(TRINKET_TESLA); + for (Capsule& link : game.sim.weapons.chain_get(game.sim, fighter)) + bolt_draw(game.renderer, {link.from, link.to, width}, BOLT_JITTER, game.color_get(i)); + } } -// 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. +// The Display Collision setting: every attack's hit shapes as the hit test sees them (and a Vacuum's whole cone, where +// it pulls or blows), and the boxes fighters and dummies are hit in, see-through in their owner's colour. void Scene::collision_draw() { Renderer& renderer = game.renderer; for (Attack& attack : game.sim.weapons.attacks) { + Color color = color_fade(owner_color_get(game, attack), COLLISION_ALPHA); Capsule shapes[WHIP_RAYS]; int count = shapes_get(game.sim, attack, shapes); - capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA)); + capsules_fill(renderer, shapes, count, color); + if (cone_get(attack) <= 0) + continue; + float range = cone_reach_get(attack); + Rect bounds = {attack.position.x - range, attack.position.y - range, attack.position.x + range, + attack.position.y + range}; + shape_fill(renderer, bounds, [&](Vec2 point) { return is_in_cone(attack, point); }, color); } for (int i = 0; i < game.sim.fighterCount; i++) if (game.sim.fighters[i].isAlive) @@ -839,7 +1039,8 @@ void Scene::collision_draw() 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. +// Explosions draw before the player, so their filled centre never hides anyone standing in the blast. One that landed a +// double donk blinks bright as it lingers, like an armed mine. void Scene::blasts_draw() { for (Attack& attack : game.sim.weapons.attacks) @@ -847,9 +1048,10 @@ void Scene::blasts_draw() 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); + float radius = attack.stats.range * std::min(1.0f, attack.time / EXPLOSION_DEF.duration * EXPLOSION_GROW); Color color = color_dim(owner_color_get(game, attack), attack.bonus); - float fill = fill_get(attack.bonus); + bool isLit = attack.isDonked && std::fmod(attack.time, DONK_BLINK) < DONK_BLINK * MINE_BLINK_LIT * 2; + float fill = isLit ? 1 : 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) @@ -863,7 +1065,7 @@ void Scene::blasts_draw() Pose Scene::attack_pose(Attack& attack) { const AttackDef& def = *attack.def(); - float progress = attack.time / attack.duration; + float progress = attack.progress_get(); float side = attack.isLeft ? -1 : 1; Anm2& effects = game.sprites.effects; Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name}; @@ -889,7 +1091,7 @@ Pose Scene::attack_pose(Attack& attack) break; case ATTACK_ARROW: { - float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f); + float fade = std::clamp((attack.lifetime - attack.life_get()) / ARROW_FADE, 0.0f, 1.0f); draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1); pose = {&effects, "Arrow", draw}; break; @@ -921,7 +1123,7 @@ Pose Scene::attack_pose(Attack& attack) 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) + if (is_armed(attack) && attack.blink > mine_blink_get(attack) * (1 - MINE_BLINK_LIT)) draw.flash = 1; pose = {&effects, "Mine", draw}; break; @@ -954,9 +1156,21 @@ Pose Scene::attack_pose(Attack& attack) break; } case ATTACK_TELEPORT: - // Drawn as its marker and sparkles instead. + case ATTACK_VACUUM: + case ATTACK_GUST: + // Drawn as its marker and sparkles, or as rushing air, instead. pose.art = nullptr; break; + case ATTACK_GRENADE: + draw.scale.y = attack.scale; + pose = {&effects, "Grenade", draw}; + break; + case ATTACK_BOLT: + // Upright and unflipped, whichever way it crawls. + draw.angle = 0; + draw.scale = {attack.scale, attack.scale}; + pose = {&effects, "Bolt", draw, attack.time * effects.fps}; + break; default: break; } @@ -974,7 +1188,7 @@ void Scene::held_draw(Fighter& fighter) float side = fighter.is_left() ? -1 : 1; Draw draw = { - .color = game.color_get(fighter.index), + .color = cooldown_tint_get(fighter, held, game.color_get(fighter.index)), .position = fighter.hand_get() + weapon_shake_get(fighter, held), .scale = {1, side}, .angle = vec2_angle(fighter.aim) + kick_get(fighter) * side, @@ -1003,7 +1217,7 @@ void Scene::offhand_draw(Fighter& fighter) if (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, hand)) continue; Draw draw = { - .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), + .color = cooldown_tint_get(fighter, off, color_shade(game.color_get(fighter.index), SECONDARY_SHADE)), .position = fighter.hand_get(hand) + weapon_shake_get(fighter, off), .scale = {1, side}, .angle = vec2_angle(fighter.aim), @@ -1018,21 +1232,39 @@ void Scene::offhand_draw(Fighter& fighter) // down the lash or rope. void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw) { + // A weapon's own animation idles, looping. Its charge animation (if it has one) shows how far it's charged, and its + // cooldown one how far it's cooled down, their frames stepping through and holding on the last once full. + Anm2& art = game.sprites.weapons; Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand); std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name; + std::string charging = animation + " Charge"; + std::string cooling = animation + " Cooldown"; + Anm2Animation* charge = fighter.charge > 0 ? art.animation_get(charging.c_str()) : nullptr; + Anm2Animation* cooldown = fighter.cooldowns[weaponSlot] > 0 ? art.animation_get(cooling.c_str()) : nullptr; + float frame = SDL_GetTicks() * art.fps / SDL_MS_PER_SECOND; if (tether) { Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; draw.angle = vec2_angle(end - draw.position); animation += " Base"; } + else if (charge) + { + animation = charging; + frame = std::min(1.0f, fighter.charge) * (charge->length - 1); + } + else if (cooldown) + { + animation = cooling; + frame = (1 - cooldown_left_get(fighter, weaponSlot)) * (cooldown->length - 1); + } // A weapon without held art yet shows its icon, small and upright. - if (!game.sprites.weapons.animation_get(animation.c_str())) + if (!art.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); + art.draw(game.renderer, animation.c_str(), frame, draw); } diff --git a/src/scene.h b/src/scene.h index ab27d43..61d9779 100644 --- a/src/scene.h +++ b/src/scene.h @@ -17,8 +17,10 @@ public: void update(); void draw(); bool is_flashing(Fighter& fighter); + float top_get(Fighter& fighter); float charge_fill_get(Fighter& fighter); Vec2 weapon_shake_get(Fighter& fighter, int key); + Color cooldown_tint_get(Fighter& fighter, int key, Color color); void sound_play(SoundId sound) override; void charge_play(Fighter& fighter) override; @@ -27,13 +29,12 @@ public: void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) override; void shake_add(float amount) override; void flash_spawn(Vec2 position, float angle, float size, int owner) override; - void ring_spawn(Vec2 position, float radius, int owner) override; void fighter_afterimage_spawn(Fighter& fighter) override; void attack_afterimage_spawn(Attack& attack) override; void fighter_aura_spawn(Fighter& fighter) override; void attack_aura_spawn(Attack& attack) override; void number_spawn(Damage& damage) override; - void level_step(bool isUp) override; + void level_step(int step) override; void match_start() override; void deal_play(int fighter) override; void sandbox_return() override; @@ -41,6 +42,8 @@ public: private: Game& game; int chargeVoices[MAX_FIGHTERS]; + // Each fighter's running Vacuum's whir, or -1. + int whirVoices[MAX_FIGHTERS]; // How many of the match's tomatoes have splatted on this screen. int tomatoesSeen = 0; @@ -54,6 +57,9 @@ private: void attacks_draw(); void magic_draw(); void tesla_draw(); + void suck_draw(Attack& attack, Color color); + void gust_draw(Attack& attack, Color color); + void whirs_update(); void paths_draw(Fighter& fighter); void collision_draw(); void blasts_draw(); diff --git a/src/session.cpp b/src/session.cpp index d31db2d..a3e4c86 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -44,6 +44,7 @@ void Session::start(Game& game) names.clear(); names[game.net.selfId] = text_clean(game.settings.name, PLAYER_NAME_MAX); sentColor = game.settings.color_get(); + sentFashion = game.settings.fashion; linkDeadline = SDL_GetTicks() + LINK_WAIT; isProfileSent = false; isStatsLogged = SDL_getenv("SAB_NET_STATS"); @@ -105,7 +106,8 @@ void Session::update(Game& game, Controls local) Profile profile = profile_get(game.local_get()); SDL_strlcpy(profile.name, game.settings.name, sizeof(profile.name)); profile.color = game.settings.color_get(); - game.net.send(PacketWriter().put(PACKET_PROFILE).put(profile).bytes, true); + profile.fashion = game.settings.fashion; + game.net.send(PacketWriter().put(PACKET_PROFILE).put(profile).bytes, DELIVERY_RELIABLE); isProfileSent = true; } packets_take(game); @@ -139,7 +141,7 @@ void Session::update(Game& game, Controls local) { own[tick % SESSION_RING] = local; local.edit = local.slot = -1; - step(game, i == advance - 1); + step(game, i == advance - 1 ? STEP_LIVE : STEP_REPLAY); } inputs_send(game); local_find(game); @@ -198,6 +200,8 @@ void Session::packet_handle(Game& game, const std::string& bytes) checksums[at] = value; return; } + if (type == PACKET_ROOMS) + return rooms_take(reader); if (isGame) return; @@ -350,14 +354,14 @@ void Session::rewind(Game& game) tick = rewindTo; rewindTo = -1; while (tick < end) - step(game, false); + step(game, STEP_REPLAY); rollbacks++; } // Plays one tick, keeping the state from before it to rewind to: the room's events due then, and every fighter's // controls: the server's when they've come, this screen's own, or a guess that everyone else keeps doing what they // last did (without one-off edits or weapon switches). Bots play themselves. Replays are silent. -void Session::step(Game& game, bool isLive) +void Session::step(Game& game, StepMode mode) { Sim& sim = game.sim; SessionTick& slot = ticks[tick % SESSION_RING]; @@ -386,7 +390,7 @@ void Session::step(Game& game, bool isLive) sim.fighters[i].controls = slot.controls[i] = controls; } sim.match.isOnline = true; - game.scene.isMuted = !isLive; + game.scene.isMuted = mode == STEP_REPLAY; sim.tick(); game.scene.isMuted = false; tick++; @@ -404,7 +408,7 @@ void Session::inputs_send(Game& game) packet.put(PACKET_INPUTS).put(room).put(confirmed - 1).put(first).put((Uint16)count); for (int at = first; at < tick; at++) packet.put(own[at % SESSION_RING]); - game.net.send(packet.bytes, false); + game.net.send(packet.bytes, DELIVERY_LOSSY); } // This screen's fighter in the room (the keyboard's), if it has one. @@ -437,9 +441,52 @@ bool Session::is_queued(Game& game) } // Watching another room's game is still to come. -bool Session::is_watching() +// Whether this screen is spectating one of the lobby's games: in a match it has no fighter in. +bool Session::is_watching(Game& game) { - return false; + return isJoined && game.sim.match.state != MATCH_PRACTICE && fighter_get(game, game.net.selfId) < 0; +} + +// Asks to spectate one of the lobby's games (from its sandbox), or with 0 to go back to the sandbox. +void Session::watch_send(Game& game, int serial) +{ + game.net.send(PacketWriter().put(PACKET_WATCH).put(serial).bytes, DELIVERY_RELIABLE); +} + +// A member's colour in whichever room they're in, as the lobby last heard, or -1 if they're in none yet. +int Session::color_get(int member) +{ + for (SessionRoom& lobbyRoom : lobbyRooms) + for (auto [player, color] : lobbyRoom.players) + if (player == member) + return color; + return -1; +} + +// Who's in each of the lobby's rooms (see PACKET_ROOMS). +void Session::rooms_take(PacketReader& reader) +{ + Uint8 count = 0; + if (!reader.get(count)) + return; + std::vector taken(count); + for (SessionRoom& lobbyRoom : taken) + { + Uint8 mode = 0; + Uint8 players = 0; + if (!reader.get(lobbyRoom.room) || !reader.get(mode) || !reader.get(players)) + return; + lobbyRoom.mode = mode; + for (int k = 0; k < players; k++) + { + int member = 0; + Uint8 color = 0; + if (!reader.get(member) || !reader.get(color)) + return; + lobbyRoom.players.push_back({member, color}); + } + } + lobbyRooms = taken; } // Leaves the queued game (forfeiting it if it's still on), through this screen's controls so it happens on the same @@ -481,7 +528,7 @@ void Session::text_send(Game& game, PacketType type, const std::string& text) PacketWriter packet; packet.put(type).put((Uint16)text.size()); packet.bytes += text; - game.net.send(packet.bytes, true); + game.net.send(packet.bytes, DELIVERY_RELIABLE); } // A chat message from this screen: to the lobby through the server, or straight into the log offline. @@ -496,10 +543,10 @@ void Session::chat_send(Game& game, const std::string& text) void Session::typing_send(Game& game, bool isTyping) { if (isActive) - game.net.send(PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes, true); + game.net.send(PacketWriter().put(PACKET_TYPING).put((Uint8)isTyping).bytes, DELIVERY_RELIABLE); } -// Notices this screen's name or colour changing (in Customize, or by a chat command). A new name goes to the lobby once +// Notices this screen's name or colour changing (in Profile, or by a chat command). A new name goes to the lobby once // it's done being typed (at most every RENAME_WAIT), and a new colour goes through the game as an edit, so everyone // agrees (it's refused if someone else has it). void Session::profile_watch(Game& game) @@ -527,6 +574,12 @@ void Session::profile_watch(Game& game) if (fighter_get(game, self) >= 0) game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_COLOR, .value = color})); } + if (game.settings.fashion != sentFashion) + { + sentFashion = game.settings.fashion; + if (fighter_get(game, self) >= 0) + game.input.edit_send(DEVICE_KEYBOARD, edit_encode({.type = EDIT_FASHION, .value = sentFashion})); + } } // Seconds until this screen's name can change again in the lobby (0 when it can now, or offline). @@ -568,10 +621,17 @@ void Session::kick(Game& game, const std::string& name, const std::string& reaso // Asks the server 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, bool isOpen) +void Session::queue_send(Game& game, const Rules& rules) { if (isJoined) - game.net.send(PacketWriter().put(PACKET_QUEUE).put((Uint8)isOpen).put(rules).bytes, true); + game.net.send(PacketWriter().put(PACKET_QUEUE).put((Uint8)1).put(rules).bytes, DELIVERY_RELIABLE); +} + +// Stops queueing this screen's game. +void Session::queue_cancel(Game& game) +{ + if (isJoined) + game.net.send(PacketWriter().put(PACKET_QUEUE).put((Uint8)0).put(Rules{}).bytes, DELIVERY_RELIABLE); } // How long a member's connection to the server takes there and back, in milliseconds: only this screen's own is known. @@ -590,5 +650,6 @@ void Session::ping_report(Game& game) if (ping < 0 || SDL_GetTicks() < nextPingReport) return; nextPingReport = SDL_GetTicks() + PING_REPORT_INTERVAL; - game.net.send(PacketWriter().put(PACKET_PING).put((Uint16)std::min(ping, (float)UINT16_MAX)).bytes, true); + game.net.send(PacketWriter().put(PACKET_PING).put((Uint16)std::min(ping, (float)UINT16_MAX)).bytes, + DELIVERY_RELIABLE); } diff --git a/src/session.h b/src/session.h index 1e9eb53..cd5940e 100644 --- a/src/session.h +++ b/src/session.h @@ -24,6 +24,21 @@ struct SessionTick int tick = -1; }; +// Whether a tick played is the one this screen shows (with its sounds and effects), or replayed on the way to it. +enum StepMode +{ + STEP_REPLAY, + STEP_LIVE +}; + +// One of the lobby's rooms as the server last told it: its mode, and the members playing in it with their colours. +struct SessionRoom +{ + std::vector> players; + int mode = 0; + int room = 0; +}; + // The server's frame for a tick: every fighter's controls as it played them, after the events before them. struct SessionFrame { @@ -47,11 +62,15 @@ public: bool isViewChanged = false; int rollbacks = 0; int stalls = 0; + // Who's in each of the lobby's rooms, the sandbox first. + std::vector lobbyRooms; void update(Game& game, Controls local); void stop(Game& game); bool is_wanted(Game& game); - bool is_watching(); + bool is_watching(Game& game); + void watch_send(Game& game, int serial); + int color_get(int member); bool is_playing_arena(Game& game); bool is_queued(Game& game); void arena_leave(Game& game); @@ -62,7 +81,8 @@ public: 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, bool isOpen = true); + void queue_send(Game& game, const Rules& rules); + void queue_cancel(Game& game); float ping_get(Game& game, int member); private: @@ -84,6 +104,7 @@ private: int lead = 0; int paced = 0; int sentColor = -1; + int sentFashion = FASHION_NONE; // When a newcomer gives up waiting for the WebRTC link, and whether its fighter has been brought in. Uint64 linkDeadline = 0; bool isProfileSent = false; @@ -100,9 +121,10 @@ private: void packet_handle(Game& game, const std::string& bytes); void state_load(Game& game, PacketReader& reader, int serial, int at); void frames_take(PacketReader& reader); + void rooms_take(PacketReader& reader); void confirm(Game& game); void rewind(Game& game); - void step(Game& game, bool isLive); + void step(Game& game, StepMode mode); void inputs_send(Game& game); void local_find(Game& game); void profile_watch(Game& game); diff --git a/src/settings.cpp b/src/settings.cpp index 79a5470..c7cbd17 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -8,12 +8,14 @@ #endif constexpr int SETTINGS_LENGTH = 16384; +constexpr int LOADOUT_PASSES_MAX = 6; 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; +// Colour and fashion slots: their size and the gap between, and the mark on Unassigned's. +constexpr float CHOICE_GAP = 4; +constexpr float CHOICE_SIZE = 32; +constexpr const char* UNASSIGNED_MARK = "?"; constexpr float LABEL_WIDTH = 104; constexpr float NUMBER_WIDTH = 84; constexpr float PERCENT = 100; @@ -26,6 +28,7 @@ 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_FASHION = "fashion"; constexpr const char* KEY_FULLSCREEN = "fullscreen"; constexpr const char* KEY_MUTE = "mute"; constexpr const char* KEY_NAME = "name"; @@ -33,9 +36,11 @@ 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_LOADOUT = "loadout_"; constexpr const char* KEY_SERVER_URL = "server_url"; constexpr const char* KEY_RULES_OPEN = "rules_open"; constexpr float FOOTER_GAP = 4; +constexpr float LOADOUT_GAP = 4; constexpr const char* KEY_HUD_DPS = "hud_dps"; constexpr const char* KEY_HUD_SPEED = "hud_speed"; constexpr const char* KEY_VSYNC = "vsync"; @@ -114,9 +119,9 @@ static void rules_read(Rules& rules, const char* key, const char* value) if (field.flag) rules.*field.flag = SDL_atoi(value); } - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { - ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets; + ItemRules& items = rules.items_get(kind); int prefix = SDL_strlen(ITEM_PREFIXES[kind]); if (SDL_strncmp(key, ITEM_PREFIXES[kind], prefix) != 0) continue; @@ -150,9 +155,9 @@ static int rules_write(Rules& rules, char* text, int size) 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++) + for (ItemKind kind : ITEM_KINDS) { - ItemRules& items = kind == 0 ? rules.weapons : rules.trinkets; + ItemRules& items = rules.items_get(kind); 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)); @@ -161,22 +166,24 @@ static int rules_write(Rules& rules, char* text, int size) } // 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) +static int item_find(ItemKind kind, const char* name) { - int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + int count = item_count_get(kind); for (int i = 0; i < count; i++) - if (SDL_strcmp(name, isTrinket ? TRINKET_DEFS[i].name : WEAPON_DEFS[i].name) == 0) + if (SDL_strcmp(name, item_name_get(kind, i)) == 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) +// "name:level" (into levels by slot), or item levels as "name:level" (into levels by item). Returns how many entries +// it read. +static int list_read(char* value, ItemKind kind, int* loadout, int* levels) { - int count = loadout ? SLOT_MAX : (isTrinket ? TRINKET_COUNT : WEAPON_COUNT); + int count = loadout ? SLOT_MAX : item_count_get(kind); char* entry = value; - for (int index = 0; entry && index < count; index++) + int index = 0; + for (; entry && index < count; index++) { char* rest = SDL_strchr(entry, ','); if (rest) @@ -184,13 +191,34 @@ static void list_read(char* value, bool isTrinket, int* loadout, int* levels) char* colon = SDL_strchr(entry, ':'); if (colon) *colon++ = '\0'; - int item = item_find(isTrinket, entry); + int item = item_find(kind, 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; } + return index; +} + +// Writes a loadout's first count slots as list_read reads them, ending the line. +static int list_write(char* text, int size, ItemKind kind, int* loadout, int* levels, int count) +{ + int length = 0; + for (int slot = 0; slot < count; 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", item_name_get(kind, item), levels[slot] + 1); + } + return length + SDL_snprintf(text + length, size - length, "\n"); +} + +// A saved loadout's key for one kind, like "loadout_3_weapons". +static void loadout_key_get(char* key, int size, int index, ItemKind kind) +{ + SDL_snprintf(key, size, "%s%d_%s", KEY_LOADOUT, index + 1, kind == ITEM_TRINKET ? "trinkets" : "weapons"); } static void sandbox_read(SandboxSave& sandbox, const char* key, char* value) @@ -205,13 +233,13 @@ static void sandbox_read(SandboxSave& sandbox, const char* key, char* value) else if (SDL_strcmp(key, KEY_SANDBOX_PVP) == 0) sandbox.isPvp = SDL_atoi(value) != 0; else if (SDL_strcmp(key, KEY_SANDBOX_WEAPONS) == 0) - list_read(value, false, sandbox.weaponLoadout, sandbox.weaponSlotLevels); + list_read(value, ITEM_WEAPON, sandbox.weaponLoadout, sandbox.weaponSlotLevels); else if (SDL_strcmp(key, KEY_SANDBOX_TRINKETS) == 0) - list_read(value, true, sandbox.trinketLoadout, sandbox.trinketSlotLevels); + list_read(value, ITEM_TRINKET, sandbox.trinketLoadout, sandbox.trinketSlotLevels); else if (SDL_strcmp(key, KEY_SANDBOX_WEAPON_LEVELS) == 0) - list_read(value, false, nullptr, sandbox.weaponLevels); + list_read(value, ITEM_WEAPON, nullptr, sandbox.weaponLevels); else if (SDL_strcmp(key, KEY_SANDBOX_TRINKET_LEVELS) == 0) - list_read(value, true, nullptr, sandbox.trinketLevels); + list_read(value, ITEM_TRINKET, nullptr, sandbox.trinketLevels); else isSandbox = false; sandbox.isSaved |= isSandbox; @@ -222,29 +250,19 @@ 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%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, KEY_SANDBOX_PVP, (int)fighter.isPvp); - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { - 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); - } + int* loadout = fighter.loadout_get(kind); + int* slotLevels = fighter.slot_levels_get(kind); 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; + "%s=", kind == ITEM_TRINKET ? KEY_SANDBOX_TRINKETS : KEY_SANDBOX_WEAPONS); + length += list_write(text + length, size - length, kind, loadout, slotLevels, SLOT_MAX); + length += SDL_snprintf(text + length, size - length, + "%s=", kind == ITEM_TRINKET ? KEY_SANDBOX_TRINKET_LEVELS : KEY_SANDBOX_WEAPON_LEVELS); + int count = item_count_get(kind); 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); + item_name_get(kind, item), fighter.item_levels_get(kind)[item] + 1); length += SDL_snprintf(text + length, size - length, "\n"); } return length; @@ -262,6 +280,9 @@ void Settings::load(Game& game) 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); + for (Loadout& loadout : loadouts) + for (int* items : loadout.items) + std::fill(items, items + SLOT_MAX, -1); savedRules = game.sim.match.rules; char* text = (char*)SDL_LoadFile(file_path_get().c_str(), nullptr); if (!text) @@ -301,12 +322,25 @@ void Settings::load(Game& game) isCollisionShown = number; if (SDL_strcmp(line, KEY_COLOR) == 0) color = std::clamp(number, COLOR_UNASSIGNED, COLOR_COUNT - 1); + if (SDL_strcmp(line, KEY_FASHION) == 0) + fashion = fashion_clean(number); 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.sim.match.rules, line, value); sandbox_read(sandbox, line, value); + for (int i = 0; i < LOADOUT_COUNT; i++) + { + Loadout& loadout = loadouts[i]; + for (ItemKind kind : ITEM_KINDS) + { + char key[LINE_LENGTH]; + loadout_key_get(key, sizeof(key), i, kind); + if (SDL_strcmp(line, key) == 0) + loadout.slots[kind] = list_read(value, kind, loadout.items[kind], loadout.levels[kind]); + } + } for (int i = 0; i < CONTROL_COUNT; i++) { char key[LINE_LENGTH]; @@ -331,6 +365,7 @@ void Settings::save(Game& game) KEY_TRINKET_SLOTS, trinketSlots, KEY_COLLISION, isCollisionShown, KEY_SHAKE, isShaking, KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown, KEY_SERVER_URL, serverUrl, KEY_RULES_OPEN, isRulesOpen); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n", KEY_FASHION, fashion); for (int i = 0; i < CONTROL_COUNT; i++) { Binding binding = game.input.bindings[i]; @@ -341,10 +376,24 @@ void Settings::save(Game& game) 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); + for (int i = 0; i < LOADOUT_COUNT; i++) + { + Loadout& loadout = loadouts[i]; + for (ItemKind kind : ITEM_KINDS) + { + if (!loadout.is_saved()) + continue; + char key[LINE_LENGTH]; + loadout_key_get(key, sizeof(key), i, kind); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=", key); + length += list_write(text + length, sizeof(text) - length, kind, loadout.items[kind], loadout.levels[kind], + loadout.slots[kind]); + } + } 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. + // The prefs folder is browser storage (see web/web_pre.js), written back after every save. EM_ASM(FS.syncfs(false, function(error){});); #endif isDirty = false; @@ -398,6 +447,7 @@ void Settings::draw(Game& game, Rect area, Page& page) 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}; + ui.hint_set(game, footer, "Resets everything here\nbut the controls."); if (ui.button_draw(game, footer, "Default")) defaults_restore(game); area.h -= WIDGET_HEIGHT + FOOTER_GAP; @@ -411,12 +461,14 @@ void Settings::draw(Game& game, Rect area, Page& page) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Sandbox"); if (ui.row_get(page, ROW_HEIGHT, y)) { + ui.hint_set(game, {row.x, y, row.w, row.h}, "How many weapons you\ncarry in the sandbox."); 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.hint_set(game, {row.x, y, row.w, row.h}, "How many trinkets you\ncarry in the sandbox."); 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}); @@ -424,7 +476,11 @@ void Settings::draw(Game& game, Rect area, Page& page) bool isPvp = game.local_get().isPvp; bool isPvpChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, + "Lets your attacks hit other\nfighters in the sandbox\nwho have it on too."); 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]; @@ -441,12 +497,18 @@ void Settings::draw(Game& game, Rect area, Page& page) bool isVsyncChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Fills the whole screen."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, + "Matches frames to your\nscreen, stopping tearing."); 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)) + { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shakes the screen on\nbig hits and blasts."); isShakeChanged = ui.toggle_draw(game, {x, y}, "Screenshake", isShaking); + } if (isFullscreenChanged) fullscreen_apply(game); if (isVsyncChanged) @@ -457,27 +519,19 @@ void Settings::draw(Game& game, Rect area, Page& page) bool isHudChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shows the damage you\ndeal each second."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, "Shows how fast you move."); 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)) { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shows where your\nattack will go."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, "Shows the world's\ncollision lines."); 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.separator_draw(game, {row.x, y, row.w, row.h}, "Audio"); bool isSound = false; @@ -485,12 +539,16 @@ void Settings::draw(Game& game, Rect area, Page& page) { char percent[LINE_LENGTH]; SDL_snprintf(percent, sizeof(percent), "%d%%", (int)std::round(volume * PERCENT)); + ui.hint_set(game, {row.x, y, row.w, row.h}, "How loud everything is."); 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)) + { + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Silences all sound."); isSound |= ui.toggle_draw(game, {x, y}, "Mute", isMuted); + } if (isSound) game.audio.gain_set(isMuted ? 0 : volume); @@ -504,6 +562,24 @@ void Settings::draw(Game& game, Rect area, Page& page) save(game); } +// What each control does, for its hint (see Ui::hint_set). +constexpr const char* CONTROL_HINTS[CONTROL_COUNT] = { + "Runs left.", + "Runs right.", + "Jumps. Held in the air, floats.\nOn a grapple, reels in.", + "Crouches, drops through\nplatforms and falls fast.\nOn a grapple, reels out.", + "Jumps.", + "Attacks with your weapon.", + "Hold to charge a stronger\nattack, let go to unleash it.", + "Lowers your weapon's\nlevel in the sandbox.", + "Raises your weapon's\nlevel in the sandbox.", + "Readies up for a round.\nIn the sandbox, resets you.", + "Switches to your\nnext weapon.", + "Switches to your\nprevious weapon.", + "Opens and closes the menu.", + "Opens chat.", +}; + // 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) @@ -517,6 +593,7 @@ void Settings::controls_draw(Game& game, Page& page, Rect row) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Controls"); if (ui.row_get(page, ROW_HEIGHT, y)) { + ui.hint_set(game, {row.x, y, LABEL_WIDTH - WIDGET_GAP, WIDGET_HEIGHT}, "Resets every binding."); if (ui.button_draw(game, {row.x, y, LABEL_WIDTH - WIDGET_GAP, WIDGET_HEIGHT}, "Default")) { input.bindings_reset(); @@ -529,22 +606,23 @@ void Settings::controls_draw(Game& game, Page& page, Rect row) { if (!ui.row_get(page, ROW_HEIGHT, y)) continue; + ui.hint_set(game, {row.x, y, LABEL_WIDTH, row.h}, CONTROL_HINTS[i]); ui.label_draw(game, CONTROL_NAMES[i], {row.x, y}); - for (int side = 0; side < 2; side++) + for (BindingSide side : {SIDE_KEY, SIDE_PAD}) { - 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; + Rect cell = {side == SIDE_PAD ? padX : keyX, y, BINDING_WIDTH, WIDGET_HEIGHT}; + ui.hint_set(game, cell, "Click to rebind.\nRight-click to clear."); + int& binding = input.bindings[i].side_get(side); + bool isCapturing = input.captureControl == i && input.captureSide == side; char name[LINE_LENGTH]; if (isCapturing) SDL_strlcpy(name, "Press...", sizeof(name)); else - input.binding_name_get(binding, isGamepad, name, sizeof(name)); + input.binding_name_get(binding, side, name, sizeof(name)); if (ui.button_draw(game, cell, name, &binding)) { input.captureControl = i; - input.isCaptureGamepad = isGamepad; + input.captureSide = side; } 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))) @@ -563,7 +641,7 @@ int Settings::color_get() } // 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). +// their own) and the name and colour (which are Profile's). void Settings::defaults_restore(Game& game) { Settings fresh; @@ -588,34 +666,101 @@ void Settings::defaults_restore(Game& game) 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) +// The lobby server's host, typed into a field (shown on the Multiplayer page), going back to the default when emptied +// and saved once it's no longer being typed in. +void Settings::host_draw(Game& game, Rect field) { 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++) + isDirty |= ui.field_draw(game, field, serverUrl, sizeof(serverUrl)); + if (serverUrl[0] == '\0' && !ui.is_active(serverUrl)) + SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); + if (isDirty && !ui.is_active(serverUrl)) + save(game); +} + +// The Profile tab: the screen name shown over this screen's fighter, the colour it asks for in matches (a swatch, or +// Unassigned for whichever is free) and what it wears, each in slots under a separator, and the loadouts, scrolling +// when they run long, with the fighter previewed on the right in them, walking. An emptied name goes back to the +// default. +void Settings::profile_draw(Game& game, Rect area, Page& page) +{ + Ui& ui = game.ui; + Rect list = {area.x, area.y, area.w - FIGHTER_PREVIEW_WIDTH - LOADOUT_GAP, area.h}; + float y = 0; + ui.page_begin(game, page, list); + if (ui.row_get(page, ROW_HEIGHT, y)) { - 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); + ui.hint_set(game, {area.x, y, LABEL_WIDTH + FIELD_WIDTH, WIDGET_HEIGHT}, + "Your name, shown over\nyour fighter."); + ui.label_draw(game, "Name", {area.x, y}); + isDirty |= ui.field_draw(game, {area.x + LABEL_WIDTH, y, FIELD_WIDTH, WIDGET_HEIGHT}, name, sizeof(name)); } - // 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); + // Slots fill rows across the list, a page row each. draw is given each slot's place in its list and box. + int columns = std::max(1, (int)((list.w - PAGE_SCROLL_WIDTH + CHOICE_GAP) / (CHOICE_SIZE + CHOICE_GAP))); + auto slots_draw = [&](int count, auto draw) { + for (int first = 0; first < count; first += columns) + if (ui.row_get(page, CHOICE_SIZE + CHOICE_GAP, y)) + for (int i = first; i < std::min(count, first + columns); i++) + draw(i, Rect{list.x + (i - first) * (CHOICE_SIZE + CHOICE_GAP), y, CHOICE_SIZE, CHOICE_SIZE}); + }; + Rect separator = {list.x, 0, list.w - PAGE_SCROLL_WIDTH, WIDGET_HEIGHT}; + Tooltip hovered; + Color worn = color >= 0 ? PALETTE[color] : WHITE; + + // The colours, then Unassigned (for whichever's free) last. Hovering one names it, in a window of that colour. + if (ui.row_get(page, ROW_HEIGHT, separator.y)) + ui.separator_draw(game, separator, "Color"); + slots_draw(COLOR_COUNT + 1, [&](int i, Rect box) { + if (is_point_in_rect(game.input.mouse, box)) + { + hovered.name = i < COLOR_COUNT ? COLOR_NAMES[i] : "Unassigned"; + hovered.border = i < COLOR_COUNT ? PALETTE[i] : CLEAR; + } + if (i < COLOR_COUNT) + { + isDirty |= ui.swatch_draw(game, box, PALETTE[i], color, i); + return; + } + isDirty |= ui.choice_draw(game, box, color, COLOR_UNASSIGNED); + Vec2 mark = game.text.size_get(UNASSIGNED_MARK); + game.text.draw( + game.renderer, UNASSIGNED_MARK, + {.color = ui.tint, + .position = {std::round(box.x + (box.w - mark.x) / 2), std::round(box.y + (box.h - mark.y) / 2)}}); + }); + + // What to wear, with None last. Hovering an item shows its tooltip: its quote. + if (ui.row_get(page, ROW_HEIGHT, separator.y)) + ui.separator_draw(game, separator, "Fashion"); + slots_draw(FASHION_COUNT, [&](int i, Rect box) { + int item = (i + 1) % FASHION_COUNT; + isDirty |= ui.choice_draw(game, box, fashion, item); + game.sprites.fashion_icon_draw(game.renderer, item, box, worn); + if (item == FASHION_NONE || !is_point_in_rect(game.input.mouse, box)) + return; + hovered.name = FASHION_DEFS[item].name; + hovered.fashion = item; + SDL_strlcpy(hovered.stats, "Fashion", sizeof(hovered.stats)); + SDL_snprintf(hovered.flavor, sizeof(hovered.flavor), "\n\"%s\"", FASHION_DEFS[item].flavor); + }); + + // Saving the sandbox's loadout, and loading saved ones: a row of buttons with the saved items' icons under it. + if (ui.row_get(page, ROW_HEIGHT, separator.y)) + ui.separator_draw(game, separator, "Loadouts"); + float icons = (ICON_PIXELS[ICON_SMALL] + LOADOUT_GAP) * (int)ITEM_KIND_COUNT; + if (ui.row_get(page, ROW_HEIGHT + icons, y)) + loadouts_draw(game, {list.x, y, separator.w, WIDGET_HEIGHT}, hovered); + ui.page_end(game, page); + + // The preview: the fighter in the colour picked and what it wears, its name over it, and under it the colour's name + // (or what Unassigned means) and the worn item's. + char caption[LINE_LENGTH * 2]; + SDL_snprintf(caption, sizeof(caption), "%s%s%s", color >= 0 ? COLOR_NAMES[color] : "Given at match start", + fashion != FASHION_NONE ? "\n" : "", fashion != FASHION_NONE ? FASHION_DEFS[fashion].name : ""); + ui.fighter_preview_draw(game, area, name, worn, caption, fashion); + if (*hovered.name) + ui.tooltip_draw(game, hovered); if (name[0] == '\0' && !ui.is_active(name)) { SDL_strlcpy(name, DEFAULT_NAME, sizeof(name)); @@ -633,7 +778,7 @@ void Settings::restore(Game& game) Fighter& fighter = game.local_get(); if (!sandbox.isSaved) { - fighter.weaponLoadout[0] = item_find(false, SANDBOX_START_WEAPON); + fighter.weaponLoadout[0] = item_find(ITEM_WEAPON, SANDBOX_START_WEAPON); return; } std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout); @@ -683,6 +828,155 @@ void Settings::update(Game& game) isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; }); #endif wasPractice = isPractice; + loadouts_update(game); if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window)) save(game); } + +// Keeps the sandbox's loadout (slot counts, items and their copies' levels) as a saved loadout. +void Settings::loadout_save(Game& game, int index) +{ + Fighter& fighter = game.local_get(); + Loadout& loadout = loadouts[index]; + for (ItemKind kind : ITEM_KINDS) + { + std::copy(fighter.loadout_get(kind), fighter.loadout_get(kind) + SLOT_MAX, loadout.items[kind]); + std::copy(fighter.slot_levels_get(kind), fighter.slot_levels_get(kind) + SLOT_MAX, loadout.levels[kind]); + loadout.slots[kind] = fighter.slots_get(kind); + } + isDirty = true; +} + +// Starts putting a saved loadout on this screen's fighter (see loadouts_update). The sandbox takes its slot counts too. +void Settings::loadout_load(Game& game, int index) +{ + Loadout& loadout = loadouts[index]; + if (!loadout.is_saved()) + return; + loadoutPending = index; + loadoutPasses = 0; + if (game.sim.match.state != MATCH_PRACTICE) + return; + weaponSlots = loadout.slots[ITEM_WEAPON]; + trinketSlots = loadout.slots[ITEM_TRINKET]; + isDirty = true; +} + +// Whether this screen's fighter has readied for a match's fight, after which its loadout can't change. +static bool is_readied(Game& game) +{ + Match& match = game.sim.match; + return match.state == MATCH_COUNTDOWN && match.isReady[game.local]; +} + +// Shift and a number key loads that loadout. A loadout being loaded goes in as edits through this screen's controls +// (so every machine sees it on the same tick), a batch whenever the last has gone through, until the fighter has it. +// In the sandbox that's all of it. In a match it's only the items of each kind being chosen, as many as fit its +// slots in order (each once, unless duplicates are allowed), at the match's levels. +void Settings::loadouts_update(Game& game) +{ + if (game.input.loadoutPressed >= 0 && !SDL_TextInputActive(game.window) && !is_readied(game)) + loadout_load(game, game.input.loadoutPressed); + game.input.loadoutPressed = -1; + int device = game.devices[game.local]; + if (loadoutPending < 0 || game.input.devices[device].editCount > 0) + return; + if (loadoutPasses++ >= LOADOUT_PASSES_MAX) + { + loadoutPending = -1; + return; + } + + Loadout& loadout = loadouts[loadoutPending]; + Match& match = game.sim.match; + Fighter& fighter = game.local_get(); + bool isSandbox = match.state == MATCH_PRACTICE; + bool isSent = false; + auto send = [&](Edit edit) { + game.input.edit_send(device, edit_encode(edit)); + isSent = true; + }; + if (isSandbox && + (fighter.weaponSlots != loadout.slots[ITEM_WEAPON] || fighter.trinketSlots != loadout.slots[ITEM_TRINKET])) + send({.type = EDIT_SLOTS, .index = loadout.slots[ITEM_WEAPON], .value = loadout.slots[ITEM_TRINKET]}); + for (ItemKind kind : ITEM_KINDS) + { + if (!isSandbox && !match.is_picking(kind)) + continue; + int* items = loadout.items[kind]; + int* levels = loadout.levels[kind]; + int* own = fighter.loadout_get(kind); + int* ownLevels = fighter.slot_levels_get(kind); + int saved = loadout.slots[kind]; + int slots = isSandbox ? saved : fighter.slots_get(kind); + bool isDuplicates = isSandbox || match.rules.items_get(kind).isDuplicates; + + // What goes in each slot: the sandbox's exactly, a match's packed into its slots. + int wanted[SLOT_MAX]; + int count = 0; + for (int i = 0; i < saved && count < slots; i++) + { + bool isTaken = std::find(wanted, wanted + count, items[i]) != wanted + count; + if (isSandbox || (items[i] >= 0 && (isDuplicates || !isTaken))) + wanted[count++] = items[i]; + } + for (int i = 0; i < count; i++) + { + if (own[i] != wanted[i]) + send({.type = EDIT_ITEM, .kind = kind, .index = i, .value = wanted[i] < 0 ? EDIT_EMPTY : wanted[i]}); + if (isSandbox && wanted[i] >= 0 && ownLevels[i] != levels[i]) + send({.type = EDIT_SLOT_LEVEL, .kind = kind, .index = i, .value = levels[i]}); + } + } + // Loading one that changes anything sounds like equipping it. + if (isSent && loadoutPasses == 1) + game.audio.play(SOUND_EQUIP); + if (!isSent) + loadoutPending = -1; +} + +// The loadout picker: which saved loadout, buttons to save the sandbox's into it (in the sandbox) and load it, and its +// items under them, weapons then trinkets. +void Settings::loadouts_draw(Game& game, Rect row, Tooltip& hovered) +{ + Ui& ui = game.ui; + Match& match = game.sim.match; + bool isSandbox = match.state == MATCH_PRACTICE; + char equip[LINE_LENGTH]; + SDL_snprintf(equip, sizeof(equip), "Shift + %d to Equip", loadoutShown); + ui.label_draw(game, equip, {row.x, row.y}); + float x = row.x + LABEL_WIDTH; + ui.number_draw(game, {x, row.y, NUMBER_WIDTH, WIDGET_HEIGHT}, loadoutShown, {.min = 1, .max = LOADOUT_COUNT}); + x += NUMBER_WIDTH + LOADOUT_GAP; + float width = std::floor((row.x + row.w - x - (isSandbox ? LOADOUT_GAP : 0)) / (isSandbox ? 2 : 1)); + int index = loadoutShown - 1; + Loadout& loadout = loadouts[index]; + if (isSandbox && ui.button_draw(game, {x, row.y, width, WIDGET_HEIGHT}, "Save")) + loadout_save(game, index); + x += isSandbox ? width + LOADOUT_GAP : 0; + bool isPicking = isSandbox || (match.is_choosing() && !is_readied(game)); + ui.widgets_enable(loadout.is_saved() && isPicking); + if (ui.button_draw(game, {x, row.y, width, WIDGET_HEIGHT}, "Load") && loadout.is_saved() && isPicking) + loadout_load(game, index); + ui.widgets_enable(true); + + float size = ICON_PIXELS[ICON_SMALL]; + for (ItemKind kind : ITEM_KINDS) + { + float y = row.y + ROW_HEIGHT + (int)kind * (size + LOADOUT_GAP) + size / 2; + int count = loadout.slots[kind]; + int* items = loadout.items[kind]; + if (!loadout.is_saved() && kind == ITEM_WEAPON) + ui.label_draw(game, "Nothing saved", {row.x + LABEL_WIDTH, row.y + ROW_HEIGHT}); + for (int i = 0; i < count; i++) + { + if (items[i] < 0) + continue; + Vec2 centre = {row.x + LABEL_WIDTH + i * (size + LOADOUT_GAP) + size / 2, y}; + ui.icon_draw(game, kind, items[i], {.color = ui.tint, .position = centre}, ICON_SMALL); + Rect box = {centre.x - size / 2, centre.y - size / 2, size, size}; + if (is_point_in_rect(game.input.mouse, box)) + hovered = ui.tooltip_build(game, kind, items[i], loadout.levels[kind][i]); + } + } +} diff --git a/src/settings.h b/src/settings.h index a509ebc..3175aaa 100644 --- a/src/settings.h +++ b/src/settings.h @@ -8,6 +8,7 @@ struct Game; struct Page; +struct Tooltip; constexpr int NAME_LENGTH = 16; @@ -26,9 +27,25 @@ struct SandboxSave bool isSaved = false; }; +constexpr int LOADOUT_COUNT = 9; + +// A saved loadout: each kind's items by slot (-1 for empty) with their copies' levels, and how many slots it had +// (none for a loadout never saved). +struct Loadout +{ + int items[ITEM_KIND_COUNT][SLOT_MAX]; + int levels[ITEM_KIND_COUNT][SLOT_MAX] = {}; + int slots[ITEM_KIND_COUNT] = {}; + + bool is_saved() + { + return slots[ITEM_WEAPON] > 0; + } +}; + // 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 +// Settings tab edits them (and the control bindings, which live in Input), and Profile 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 @@ -39,6 +56,7 @@ public: char name[NAME_LENGTH + 1] = "Fighter"; float volume = 1; int color = COLOR_UNASSIGNED; + int fashion = FASHION_NONE; int trinketSlots = 2; int weaponSlots = 2; bool isAimShown = true; @@ -52,6 +70,9 @@ public: // 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; + // The saved loadouts, and which one the loadout picker shows (counting from 1). + Loadout loadouts[LOADOUT_COUNT]; + int loadoutShown = 1; Settings(); void load(Game& game); @@ -60,7 +81,8 @@ public: 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 profile_draw(Game& game, Rect area, Page& page); + void host_draw(Game& game, Rect field); void defaults_restore(Game& game); int color_get(); // Marks the settings for saving (as the menu does), for changes made elsewhere, like chat commands. @@ -70,6 +92,7 @@ public: } void restore(Game& game); void update(Game& game); + void loadouts_draw(Game& game, Rect row, Tooltip& hovered); private: SandboxSave sandbox; @@ -77,6 +100,12 @@ private: Uint64 savedAt = 0; bool isDirty = false; bool wasPractice = true; + int loadoutPasses = 0; + int loadoutPending = -1; + + void loadout_save(Game& game, int index); + void loadout_load(Game& game, int index); + void loadouts_update(Game& game); void controls_draw(Game& game, Page& page, Rect area); }; diff --git a/src/sim.cpp b/src/sim.cpp index 8cb5730..68d3324 100644 --- a/src/sim.cpp +++ b/src/sim.cpp @@ -33,11 +33,13 @@ void Sim::game_start(Match& lobby, const int* members, int count, const Rules& r { int owners[MAX_FIGHTERS]; int colors[MAX_FIGHTERS]; + int fashions[MAX_FIGHTERS]; char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1]; for (int k = 0; k < count; k++) { owners[k] = lobby.owners[members[k]]; colors[k] = lobby.colors[members[k]]; + fashions[k] = lobby.fashions[members[k]]; std::memcpy(names[k], lobby.names[members[k]], sizeof(names[k])); } int players = rules.isBotFill ? std::max(rules.players, count) : count; @@ -64,6 +66,7 @@ void Sim::game_start(Match& lobby, const int* members, int count, const Rules& r if (k < count) { match.colors[k] = colors[k]; + match.fashions[k] = fashions[k]; std::memcpy(match.names[k], names[k], sizeof(match.names[k])); continue; } @@ -97,7 +100,7 @@ void level_step(Sim& sim, int& level, int step) int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); if (next == level) return; - sim.cues->level_step(next > level); + sim.cues->level_step(next - level); level = next; } diff --git a/src/sim.h b/src/sim.h index 023628d..4e450a5 100644 --- a/src/sim.h +++ b/src/sim.h @@ -23,8 +23,6 @@ public: {} virtual void flash_spawn(Vec2 position, float angle, float size, int owner) {} - virtual void ring_spawn(Vec2 position, float radius, int owner) - {} virtual void fighter_afterimage_spawn(Fighter& fighter) {} virtual void attack_afterimage_spawn(Attack& attack) @@ -35,7 +33,7 @@ public: {} virtual void number_spawn(Damage& damage) {} - virtual void level_step(bool isUp) + virtual void level_step(int step) {} virtual void match_start() {} diff --git a/src/sprites.cpp b/src/sprites.cpp index f59b1bc..d6c3ea3 100644 --- a/src/sprites.cpp +++ b/src/sprites.cpp @@ -6,6 +6,7 @@ void Sprites::init(Game& game) Renderer& renderer = game.renderer; dummy.load(renderer, "dummy"); effects.load(renderer, "effects"); + fashion.load(renderer, "fashion"); icons.load(renderer, "weapon_icons"); player.load(renderer, "player"); tiles.load(renderer, "tiles"); @@ -16,3 +17,51 @@ void Sprites::init(Game& game) for (Animator& animator : fighters) animator.art = &player; } + +// What a pose wears, its layers on the player nulls they're named after (as drawn: placed, flipped, scaled and turned +// with the pose), in its colour. +void Sprites::fashion_draw(Renderer& renderer, Pose& pose, int worn) +{ + Anm2Animation* animation = worn > FASHION_NONE && worn < FASHION_COUNT && pose.art + ? fashion.animation_get(FASHION_DEFS[worn].name) + : nullptr; + for (int track = 0; animation && track < (int)animation->layers.size(); track++) + { + Draw placement; + const char* null = fashion.layerNames[animation->layerIds[track]].c_str(); + if (pose.art->null_get(pose.animation, null, pose.frame, pose.placement, placement)) + fashion.track_draw(renderer, *animation, track, 0, placement); + } +} + +// An item laid out in a slot's box as its art sits in its cells (each layer's cell over the box). +void Sprites::fashion_icon_draw(Renderer& renderer, int item, Rect box, Color color) +{ + Anm2Animation* animation = + item > FASHION_NONE && item < FASHION_COUNT ? fashion.animation_get(FASHION_DEFS[item].name) : nullptr; + for (int track = 0; animation && track < (int)animation->layers.size(); track++) + { + Vec2 pivot = animation->layers[track].empty() ? Vec2{} : animation->layers[track][0].pivot; + fashion.track_draw(renderer, *animation, track, 0, + {.color = color, .position = {box.x + pivot.x, box.y + pivot.y}}); + } +} + +// How high a fighter (at scale 1, feet at 0) can reach wearing an item: each of its layers' nulls at its highest in +// any animation plus how far the layer's art stands above it (from its pivot up to where its art starts), so what's +// placed over fighters can sit above it without following the frames. 0 for nothing worn. +float Sprites::fashion_height_get(int worn) +{ + Anm2Animation* animation = + worn > FASHION_NONE && worn < FASHION_COUNT ? fashion.animation_get(FASHION_DEFS[worn].name) : nullptr; + float height = 0; + for (int track = 0; animation && track < (int)animation->layers.size(); track++) + { + if (animation->layers[track].empty()) + continue; + const char* null = fashion.layerNames[animation->layerIds[track]].c_str(); + Anm2Frame& art = animation->layers[track][0]; + height = std::max(height, player.null_height_get(null) + art.pivot.y - art.top); + } + return height; +} diff --git a/src/sprites.h b/src/sprites.h index a46b55d..2bfe41c 100644 --- a/src/sprites.h +++ b/src/sprites.h @@ -12,6 +12,8 @@ class Sprites public: Anm2 dummy; Anm2 effects; + // What fighters wear, by name (see FASHION_DEFS), each layer named after the player null it goes on. + Anm2 fashion; // Weapon and trinket icons at full (24 pixel), small (16) and tiny (8) sizes, the smaller ones named with Small or // Tiny after. Anm2 icons; @@ -25,4 +27,7 @@ public: Animator fighters[MAX_FIGHTERS]; void init(Game& game); + void fashion_draw(Renderer& renderer, Pose& pose, int worn); + void fashion_icon_draw(Renderer& renderer, int item, Rect box, Color color); + float fashion_height_get(int worn); }; diff --git a/src/text.h b/src/text.h index 9a79c54..7343656 100644 --- a/src/text.h +++ b/src/text.h @@ -10,8 +10,8 @@ constexpr int GLYPH_TYPED_END = 127; // 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. +// The fonts, each an atlas with a .fnt file: 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, diff --git a/src/trinket.h b/src/trinket.h index 5b31f01..3eda460 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -10,6 +10,7 @@ enum TrinketId TRINKET_DRINK, TRINKET_BOXING, TRINKET_ACCELERATOR, + TRINKET_HOURGLASS, TRINKET_ENLARGER, TRINKET_DIAZEPAM, TRINKET_SPLITTER, @@ -31,135 +32,270 @@ enum TrinketId 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). +// A trinket's numbers at one level, each 0 unless the trinket has it. Its tooltip lists the ones it has (see +// TRINKET_STATS). +struct TrinketStats +{ + float arcRange = 0; + float attackRate = 0; + float attackSize = 0; + float chargeRate = 0; + float climb = 0; + float contactDamage = 0; + float copies = 0; + float copyDamage = 0; + float damage = 0; + float dashSpeed = 0; + float dashes = 0; + float fastFall = 0; + float floatGravity = 0; + float knockback = 0; + float knockbackTaken = 0; + float lifetime = 0; + float movement = 0; + float offhandDamage = 0; + float projectileSpeed = 0; + float recoil = 0; + float repeatDamage = 0; + float ricochets = 0; + float runSpeed = 0; + float shockDamage = 0; + float size = 0; + float slamArea = 0; + float slamDamage = 0; + float speedDamage = 0; + float spreadCut = 0; +}; + +// The stats, in the order tooltips list them. +enum TrinketStat +{ + STAT_ATTACK_RATE, + STAT_CHARGE_RATE, + STAT_PROJECTILE_SPEED, + STAT_LIFETIME, + STAT_ATTACK_SIZE, + STAT_SPREAD_CUT, + STAT_COPIES, + STAT_COPY_DAMAGE, + STAT_REPEAT_DAMAGE, + STAT_OFFHAND_DAMAGE, + STAT_RICOCHETS, + STAT_SHOCK_DAMAGE, + STAT_ARC_RANGE, + STAT_KNOCKBACK, + STAT_RECOIL, + STAT_MOVEMENT, + STAT_KNOCKBACK_TAKEN, + STAT_SIZE, + STAT_DAMAGE, + STAT_CONTACT_DAMAGE, + STAT_RUN_SPEED, + STAT_SPEED_DAMAGE, + STAT_DASH_SPEED, + STAT_DASHES, + STAT_FLOAT_GRAVITY, + STAT_FAST_FALL, + STAT_SLAM_DAMAGE, + STAT_SLAM_AREA, + STAT_CLIMB, + STAT_COUNT +}; + +// How a stat reads: a multiplier (1.15x), a share taken away as the multiplier it leaves (a sixth off reads 0.83x), a +// multiplier added (+0.05x), a number, a distance (60u), or Climb Claw's abilities. +enum StatFormat +{ + FORMAT_TIMES, + FORMAT_LEFT, + FORMAT_PLUS, + FORMAT_NUMBER, + FORMAT_UNITS, + FORMAT_CLIMB +}; + +// How equipped copies' values combine: from the neutral value, with everything over it added up and everything under +// it multiplied in (see Fighter::stat_get), or the best copy's alone. +enum StatStack +{ + STACK_SCALE, + STACK_BEST +}; + +// A stat's place in TrinketStats, its tooltip label and format, how copies combine, its value without any trinket, +// and the most copies can add up to (0 for no limit). +struct TrinketStatDef +{ + float TrinketStats::* field; + const char* label; + StatFormat format; + StatStack stack = STACK_SCALE; + float neutral = 0; + float cap = 0; +}; + +inline constexpr TrinketStatDef TRINKET_STATS[STAT_COUNT] = { + {&TrinketStats::attackRate, "Attack Rate", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::chargeRate, "Charge Speed", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::projectileSpeed, "Projectile Speed", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::lifetime, "Projectile Lifetime", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::attackSize, "Attack Size", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::spreadCut, "Spread", FORMAT_LEFT, STACK_SCALE, 0, 1}, + {&TrinketStats::copies, "Copies", FORMAT_NUMBER, STACK_SCALE, 1}, + {&TrinketStats::copyDamage, "Damage Each", FORMAT_TIMES, STACK_BEST}, + {&TrinketStats::repeatDamage, "Repeat Damage", FORMAT_TIMES, STACK_BEST}, + {&TrinketStats::offhandDamage, "Off-Hand Damage", FORMAT_TIMES, STACK_BEST}, + {&TrinketStats::ricochets, "Ricochets", FORMAT_NUMBER}, + {&TrinketStats::shockDamage, "Shock Damage", FORMAT_NUMBER}, + {&TrinketStats::arcRange, "Arc Range", FORMAT_UNITS}, + {&TrinketStats::knockback, "Knockback Dealt", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::recoil, "Recoil Power", FORMAT_NUMBER}, + {&TrinketStats::movement, "Weapon Movement", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::knockbackTaken, "Knockback Taken", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::size, "Size", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::damage, "Damage", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::contactDamage, "Contact Damage", FORMAT_NUMBER}, + {&TrinketStats::runSpeed, "Run Speed", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::speedDamage, "Damage per 100 Speed", FORMAT_PLUS}, + {&TrinketStats::dashSpeed, "Dash Speed", FORMAT_NUMBER, STACK_BEST}, + {&TrinketStats::dashes, "Dashes", FORMAT_NUMBER}, + {&TrinketStats::floatGravity, "Gravity Holding Up", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::fastFall, "Fast Fall", FORMAT_TIMES, STACK_SCALE, 1}, + {&TrinketStats::slamDamage, "Slam Damage", FORMAT_NUMBER}, + {&TrinketStats::slamArea, "Slam Area", FORMAT_UNITS}, + {&TrinketStats::climb, "Climbing", FORMAT_CLIMB, STACK_SCALE, 0, 3}, +}; + +// A trinket's name and description, its stats at each level, and a quote for its tooltip. struct TrinketDef { const char* name; const char* blurb; - const char* effects[LEVEL_COUNT]; - float values[LEVEL_COUNT]; + TrinketStats levels[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}, + {{.attackRate = 1.15f}, {.attackRate = 1.3f}, {.attackRate = 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}, + {{.chargeRate = 1.2f}, {.chargeRate = 1.35f}, {.chargeRate = 1.5f}}, "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-"}, + {{.knockback = 1.15f}, {.knockback = 1.3f}, {.knockback = 1.45f}}, + "Get ready to study hard, Mac.\nThis is called..."}, {"Accelerator", "Projectiles fly faster.", - {"1.2x Projectile Speed", "1.4x Projectile Speed", "1.6x Projectile Speed"}, - {1.2f, 1.4f, 1.6f}, + {{.projectileSpeed = 1.2f}, {.projectileSpeed = 1.4f}, {.projectileSpeed = 1.6f}}, "Gotta go fast!"}, + {"Hourglass", + "Increases projectile lifetime,\nsustaining damage for longer,\nand increasing their range.", + {{.lifetime = 7 / 6.0f}, {.lifetime = 4 / 3.0f}, {.lifetime = 1.5f}}, + "The sands of time..."}, {"Enlarger", - "Weapons and projectiles grow.", - {"1.2x Attack Size", "1.4x Attack Size", "1.6x Attack Size"}, - {1.2f, 1.4f, 1.6f}, + "Weapons and projectiles\nincrease in size.", + {{.attackSize = 1.2f}, {.attackSize = 1.4f}, {.attackSize = 1.6f}}, "A noble spirit embiggens the\nsmallest projectile."}, {"Diazepam", - "Steadies your aim: every attack,\nvolley and Splitter copy spreads less.", - {"17% Less Spread", "33% Less Spread", "50% Less Spread"}, - {1 / 6.0f, 1 / 3.0f, 0.5f}, - "Diazepam?\nPsychotropic drug?\nStops hands from trembling?\nQuestions?", - 1}, + "Decreases spread on attacks.", + {{.spreadCut = 1 / 6.0f}, {.spreadCut = 1 / 3.0f}, {.spreadCut = 0.5f}}, + "Diazepam?\nPsychotropic drug?\nStops hands from trembling?\nQuestions?"}, {"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}, + "Attacks split into smaller,\nless-damaging copies.", + {{.copies = 2, .copyDamage = 1 / 3.0f}, + {.copies = 3, .copyDamage = 1 / 3.0f}, + {.copies = 4, .copyDamage = 1 / 3.0f}}, "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}, + "Attacks will repeat a moment\nlater for additional damage.", + {{.repeatDamage = 0.1f}, {.repeatDamage = 0.25f}, {.repeatDamage = 1 / 3.0f}}, "Stud Multiplier"}, {"Dual Wield", - "Your last-held weapon fires from\nyour off hand too, for a cut of\nits damage. Each copy adds another\nweapon, " - "the next most recent.", - {"10% Off-Hand Damage", "25% Off-Hand Damage", "33% Off-Hand Damage"}, - {0.1f, 0.25f, 1 / 3.0f}, + "An off-hand weapon\nwill fire simultaneously\nwith your active one for\nless damage.", + {{.offhandDamage = 0.1f}, {.offhandDamage = 0.25f}, {.offhandDamage = 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}, + "Projectiles ricochet off of\nwalls. Each ricochet adds\nadditional damage and refreshes\nprojectile lifetime.", + {{.ricochets = 1}, {.ricochets = 2}, {.ricochets = 3}}, "RICOCHET 2 BABY!"}, {"Tesla Coil", - "Lightning chains between your\nweapons out in the world, shocking\nanything it touches.", - {"2 Damage Shocks\n120 Arc Range", "4 Damage Shocks\n170 Arc Range", "6 Damage Shocks\n220 Arc Range"}, - {2, 4, 6}, + "Electricity arcs between\nprojectiles in a range, dealing\ncontinuous damage.", + {{.arcRange = 60, .shockDamage = 1}, {.arcRange = 85, .shockDamage = 2}, {.arcRange = 110, .shockDamage = 3}}, "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.", - {"6 Recoil Power", "9 Recoil Power", "12 Recoil Power"}, - {6, 9, 12}, + "All attacks have additional\nrecoil.", + {{.recoil = 6}, {.recoil = 9}, {.recoil = 12}}, "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.", - {"17% Less Weapon Movement\n11% Less Knockback Taken", "33% Less Weapon Movement\n22% Less Knockback Taken", - "50% Less Weapon Movement\n33% Less Knockback Taken"}, - {5 / 6.0f, 2 / 3.0f, 0.5f}, + "Receive less knockback, and\nattacks incur less movement.", + {{.knockbackTaken = 8 / 9.0f, .movement = 5 / 6.0f}, + {.knockbackTaken = 7 / 9.0f, .movement = 2 / 3.0f}, + {.knockbackTaken = 2 / 3.0f, .movement = 0.5f}}, "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}, + "Decreases player size.", + {{.size = 0.9f}, {.size = 0.8f}, {.size = 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}, + "Increases player size.\nDamage multiplier.", + {{.damage = 10 / 9.0f, .size = 4 / 3.0f}, + {.damage = 11 / 9.0f, .size = 5 / 3.0f}, + {.damage = 4 / 3.0f, .size = 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}, + "Deal contact damage.", + {{.contactDamage = 6}, {.contactDamage = 10}, {.contactDamage = 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}, + "Speed and acceleration\nincreased.", + {{.runSpeed = 1.3f}, {.runSpeed = 1.65f}, {.runSpeed = 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}, + "Moving faster deals more damage.\nSpeed and acceleration\nincreased slightly.", + {{.runSpeed = 1.05f, .speedDamage = 0.05f}, + {.runSpeed = 1.1f, .speedDamage = 0.1f}, + {.runSpeed = 1.15f, .speedDamage = 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}, + "Dash in the air, following\nyour aim.", + {{.dashSpeed = 250, .dashes = 1}, {.dashSpeed = 300, .dashes = 1}, {.dashSpeed = 350, .dashes = 1}}, "Probably given to you from\nEnergy Drink."}, {"Feather", - "Hold Up in the air to float.", - {"0.75x Gravity Holding Up", "0.625x Gravity Holding Up", "0.5x Gravity Holding Up"}, - {0.75f, 0.625f, 0.5f}, + "Hold Up in the air\nto reduce gravity.", + {{.floatGravity = 0.75f}, {.floatGravity = 0.625f}, {.floatGravity = 0.5f}}, "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}, + "Fast fall speed increased.\nLanding fast spawns\nan explosion.", + {{.fastFall = 1.1f, .slamArea = 7.5f, .slamDamage = 20}, + {.fastFall = 1.2f, .slamArea = 10, .slamDamage = 35}, + {.fastFall = 1.3f, .slamArea = 12.5f, .slamDamage = 50}}, "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}, + "Grants various\nclimbing abilities.", + {{.climb = 1}, {.climb = 2}, {.climb = 3}}, + "Stolen from a wooden chest on\nthe surface."}, }; + +// The two kinds of item a fighter carries, each with its own slots. +enum ItemKind +{ + ITEM_WEAPON, + ITEM_TRINKET, + ITEM_KIND_COUNT +}; + +constexpr ItemKind ITEM_KINDS[] = {ITEM_WEAPON, ITEM_TRINKET}; + +inline int item_count_get(ItemKind kind) +{ + return kind == ITEM_TRINKET ? TRINKET_COUNT : WEAPON_COUNT; +} + +inline const char* item_name_get(ItemKind kind, int item) +{ + return kind == ITEM_TRINKET ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name; +} diff --git a/src/ui.cpp b/src/ui.cpp index d916436..997273b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -10,6 +10,8 @@ constexpr float LOADOUT_FADE = 0.25f; constexpr float LOADOUT_GAP = 4; constexpr float LOADOUT_DIVIDER = 2; constexpr float ICON_SIZE = 24; +// How big a fashion item's cell is (its art is centred in it). +constexpr float FASHION_CELL = 32; constexpr float RIVAL_BAR_MIN = 16; constexpr float RIVAL_DIVIDER = 1; constexpr float RIVAL_GAP = 4; @@ -76,7 +78,6 @@ constexpr float COOLDOWN_FADE_OUT = 0.25f; constexpr float COOLDOWN_GAP = 4; constexpr float COOLDOWN_ROW_GAP = 2; constexpr float COOLDOWN_SLIDE = 12; -constexpr float COOLDOWN_VISIBLE = 0.25f; constexpr float OFFSCREEN_ARROW_SCALE = 2; constexpr float OFFSCREEN_MARGIN = 8; constexpr float OFFSCREEN_NAME_GAP = 10; @@ -91,12 +92,12 @@ 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 int CAPTION_LENGTH = 64; +// The room between the preview's name and the top of the fighter under it. +constexpr float PREVIEW_NAME_GAP = 4; constexpr float PREVIEW_SCALE = 3; constexpr const char PREVIEW_ANIMATION[] = "Preview"; constexpr float SUMMARY_ICON_GAP = 4; @@ -115,10 +116,11 @@ 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"}; +// Stats round to hundredths. +constexpr float HUNDREDTHS = 100; constexpr float TRACK_DOT = 6; constexpr float TRACK_HEIGHT = 2; constexpr float TRACK_WIDTH = 200; @@ -167,7 +169,7 @@ constexpr const char* TIPS[] = { "Shotgun recoil can double as a jump.", }; -static CountdownInfo countdown_info_get(Game& game, bool isPad); +static CountdownInfo countdown_info_get(Game& game); // How opaque a HUD element (other than a hotbar slot) is, fading as the mouse comes over it. static float hover_alpha_get(float hover) @@ -207,8 +209,8 @@ const char* Ui::name_get(Game& game, int 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); + bool isWatching = game.session.is_watching(game); + tint = isWatching ? WHITE : game.color_get(game.local, TINT_PLAIN); shade = color_shade(tint, SECONDARY_SHADE); Vec2 mouse = game.input.mouse; @@ -241,7 +243,7 @@ void Ui::draw(Game& game) game.chat.draw(game); game.menu.draw(game); if (game.sim.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared) - countdown_aside_draw(game, game.input.devices[DEVICE_KEYBOARD].isAiming); + countdown_aside_draw(game); if (isBarsRaised) hotbar_draw(game); crosshairs_draw(game, camera); @@ -265,7 +267,7 @@ void Ui::bars_draw(Game& game) continue; Rect box = fighter.body.box; float centerX = box.x + box.w / 2; - float bottom = std::round(box.y - BAR_GAP); + float bottom = std::round(game.scene.top_get(fighter) - BAR_GAP); Color color = game.color_get(i); bool isMatch = match.state != MATCH_PRACTICE; @@ -294,7 +296,7 @@ void Ui::bars_draw(Game& game) bottom -= BAR_GAP; } - if (i != game.local || fighter.charge < CHARGE_VISIBLE || game.session.is_watching()) + if (i != game.local || fighter.charge < CHARGE_VISIBLE || game.session.is_watching(game)) continue; float fill = (fighter.charge - CHARGE_VISIBLE) / (1 - CHARGE_VISIBLE); bool isShown = fighter.charge < 1 || game.scene.is_flashing(fighter); @@ -321,15 +323,12 @@ void Ui::match_draw(Game& game) 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)); + input.control_name_get(CONTROL_MENU, key, sizeof(key)); + input.control_name_get(CONTROL_READY, 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; @@ -350,7 +349,7 @@ void Ui::match_draw(Game& game) Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; int weapons = game.local_get().weaponSlots; - Rect last = game.menu.slot_rect(false, weapons - 1, weapons); + Rect last = game.menu.slot_rect(ITEM_WEAPON, weapons - 1, weapons); float hotbarRight = last.x + last.w; float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); float center = x + scoreWidth / 2; @@ -388,8 +387,10 @@ void Ui::match_draw(Game& game) 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]; + // A spectator only sees who's ready, with nothing of their own to ready or show. + CountdownInfo info = countdown_info_get(game); + bool isWatching = game.session.is_watching(game); + bool isLocalReady = match.isReady[game.local] || isWatching; if (!isTipChosen) tip = random_get(0, std::size(TIPS)); isTipChosen = true; @@ -403,7 +404,9 @@ void Ui::match_draw(Game& game) info.ready, game.sim.fighterCount, info.key); text_center_draw(game, text, y, 1); y += LINE_HEIGHT + READY_GAP; - if (game.local_count() > 1 && (match.isWeaponPick || match.isTrinketPick)) + if (isWatching) + break; + if (game.local_count() > 1 && match.is_choosing()) { picks_draw(game, y); break; @@ -428,7 +431,7 @@ void Ui::match_draw(Game& game) } case MATCH_FIGHT: // The loadout fades out under the banner. - if (match.timer > -LOADOUT_FADE && game.local_count() == 1) + if (match.timer > -LOADOUT_FADE && game.local_count() == 1 && !game.session.is_watching(game)) { float fade = 1 + match.timer / LOADOUT_FADE; game.renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, COUNTDOWN_DIM * fade)); @@ -459,9 +462,8 @@ void Ui::match_draw(Game& game) if (isRace) y = results_draw(game, y); // The menu control leaves, as a browser keeps Escape for leaving fullscreen. - Binding menu = input.bindings[CONTROL_MENU]; char leave[TOOLTIP_LENGTH]; - input.binding_name_get(isPad ? menu.pad : menu.key, isPad, leave, sizeof(leave)); + input.control_name_get(CONTROL_MENU, leave, sizeof(leave)); if (!game.session.isActive) { SDL_snprintf(text, sizeof(text), "%s: Sandbox", leave); @@ -469,11 +471,10 @@ void Ui::match_draw(Game& game) break; } // Online, a queued game's players each say whether to play it again (listed under), or leave. - if (!game.session.is_watching()) + if (!game.session.is_watching(game)) { - Binding binding = input.bindings[CONTROL_READY]; char key[TOOLTIP_LENGTH]; - input.binding_name_get(isPad ? binding.pad : binding.key, isPad, key, sizeof(key)); + input.control_name_get(CONTROL_READY, key, sizeof(key)); SDL_snprintf(text, sizeof(text), "%s: Play Again %s: Leave", key, leave); text_center_draw(game, text, y, 1); y += LINE_HEIGHT * 2; @@ -486,7 +487,7 @@ void Ui::match_draw(Game& game) match.againTicks > 0 ? " [Starting!]" : match.isReady[i] ? " [Play Again]" : ""); - text_center_draw(game, text, y, 1, game.color_get(i, true)); + text_center_draw(game, text, y, 1, game.color_get(i, TINT_PLAIN)); y += LINE_HEIGHT; } break; @@ -557,13 +558,77 @@ float Ui::results_draw(Game& game, float y) return y; } +// A trinket stat's value as its tooltip shows it (see StatFormat), multipliers to two decimals like weapon stats. +static void trinket_stat_format(char* text, int size, TrinketStat stat, float value) +{ + constexpr const char* CLIMBS[] = {"None", "Cling", "Cling, Kick", "Cling, Kick, Jump"}; + StatFormat format = TRINKET_STATS[stat].format; + float times = std::round((format == FORMAT_LEFT ? 1 - value : value) * HUNDREDTHS) / HUNDREDTHS; + switch (format) + { + case FORMAT_TIMES: + case FORMAT_LEFT: + SDL_snprintf(text, size, "%gx", times); + break; + case FORMAT_PLUS: + SDL_snprintf(text, size, "+%gx", times); + break; + case FORMAT_NUMBER: + SDL_snprintf(text, size, "%g", value); + break; + case FORMAT_UNITS: + SDL_snprintf(text, size, "%gu", value); + break; + case FORMAT_CLIMB: + SDL_strlcpy(text, CLIMBS[std::clamp((int)value, 0, (int)std::size(CLIMBS) - 1)], size); + break; + } +} + +// A trinket's stats table: a row for each stat it has at its level, previewing the next level's value after an arrow +// where it changes (the catalog). +static void trinket_table_build(int trinket, int level, TooltipMode mode, Tooltip& tooltip) +{ + const TrinketDef& def = TRINKET_DEFS[trinket]; + bool isPreview = mode == TOOLTIP_NEXT_LEVEL; + tooltip.rowCount = 0; + for (int stat = 0; stat < STAT_COUNT && tooltip.rowCount < STAT_ROWS; stat++) + { + float now = def.levels[level].*TRINKET_STATS[stat].field; + float next = isPreview ? def.levels[level + 1].*TRINKET_STATS[stat].field : now; + if (now == 0 && next == 0) + continue; + StatRow& row = tooltip.rows[tooltip.rowCount++]; + row = {.label = TRINKET_STATS[stat].label}; + trinket_stat_format(row.values[0], STAT_LENGTH, (TrinketStat)stat, now); + if (next == now) + continue; + char after[STAT_LENGTH]; + trinket_stat_format(after, sizeof(after), (TrinketStat)stat, next); + int length = SDL_strlen(row.values[0]); + SDL_snprintf(row.values[0] + length, STAT_LENGTH - length, " → %s", after); + } +} + +// A trinket's stats at a level as lines ("Attack Rate 1.15x"), for where there's no room for its table. +static void trinket_lines_get(int trinket, int level, char* text, int size) +{ + Tooltip tooltip; + trinket_table_build(trinket, level, TOOLTIP_PLAIN, tooltip); + for (int i = 0; i < tooltip.rowCount; i++) + { + int length = SDL_strlen(text); + SDL_snprintf(text + length, size - length, "\n%s %s", tooltip.rows[i].label, tooltip.rows[i].values[0]); + } +} + // 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); + AttackSummary summary = attack_summary_get(fighter, weapon, level, VARIANT_NORMAL); if (!summary.isDamaging) - summary = attack_summary_get(fighter, weapon, level, true); + summary = attack_summary_get(fighter, weapon, level, VARIANT_CHARGED); 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, @@ -615,22 +680,25 @@ LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) 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++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; + if (style == LOADOUT_ICONS) + break; for (int i = 0; i < counts[kind]; i++) { - int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[i]; + int item = fighter.loadout_get(kind)[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); + int level = fighter.slot_levels_get(kind)[i]; + Tooltip& tooltip = layout.tooltips[kind][i] = tooltip_build(game, kind, item, level); tooltip.slot = i; 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]); + if (kind == ITEM_TRINKET && !isBrief) + { + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)", level + 1); + trinket_lines_get(item, level, tooltip.stats, sizeof(tooltip.stats)); + } else if (!isBrief) compact_stats_get(game, fighter, item, level, tooltip.stats, sizeof(tooltip.stats)); else @@ -660,22 +728,22 @@ LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) float gridWidth = width * columns + LOADOUT_GAP * (columns - 1) + (isShared ? divide : 0); float rowTop = top; layout.divider = {}; - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { 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; + float shift = isShared && kind == ITEM_TRINKET ? 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) + if (isShared && kind == ITEM_WEAPON && 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) + if (kind == ITEM_WEAPON && isDivided) { layout.divider = {0, rowTop, gridWidth, LOADOUT_DIVIDER}; rowTop += divide; @@ -684,7 +752,7 @@ LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) float left = std::round((CANVAS_WIDTH - gridWidth) / 2); layout.divider.x += left; float gridBottom = top; - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) for (int i = 0; i < counts[kind]; i++) { layout.panels[kind][i].x += left; @@ -707,12 +775,11 @@ void Ui::loadout_draw(Game& game, float alpha, float top) 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++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; for (int i = 0; i < counts[kind]; i++) { - int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[i]; + int item = fighter.loadout_get(kind)[i]; if (item < 0) continue; Rect panel = layout.panels[kind][i]; @@ -721,9 +788,9 @@ void Ui::loadout_draw(Game& game, float alpha, float top) slot_draw(game, {.rect = panel, .alpha = alpha, .item = item, - .level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i], + .level = fighter.slot_levels_get(kind)[i], .isLit = true, - .isTrinket = isTrinket}); + .kind = kind}); continue; } panel_draw(game.renderer, panel, color); @@ -741,7 +808,7 @@ void Ui::loadout_draw(Game& game, float alpha, float top) // Text centred across the canvas with its top at y. // What the countdown shows: the round's name, whole seconds left (or Starting... once everyone's ready), how many are // ready, and the key that readies up. -static CountdownInfo countdown_info_get(Game& game, bool isPad) +static CountdownInfo countdown_info_get(Game& game) { Match& match = game.sim.match; CountdownInfo info; @@ -757,8 +824,7 @@ static CountdownInfo countdown_info_get(Game& game, bool isPad) SDL_strlcpy(info.time, "Starting...", sizeof(info.time)); else SDL_snprintf(info.time, sizeof(info.time), "%d", (int)std::ceil(match.timer)); - Binding binding = game.input.bindings[CONTROL_READY]; - game.input.binding_name_get(isPad ? binding.pad : binding.key, isPad, info.key, sizeof(info.key)); + game.input.control_name_get(CONTROL_READY, info.key, sizeof(info.key)); return info; } @@ -766,12 +832,13 @@ static CountdownInfo countdown_info_get(Game& game, bool isPad) // 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) +void Ui::countdown_aside_draw(Game& game) { - CountdownInfo info = countdown_info_get(game, isPad); + CountdownInfo info = countdown_info_get(game); const char* round = info.round; int ready = info.ready; - const char* key = game.sim.match.isReady[game.local] ? nullptr : info.key; + bool isReady = game.sim.match.isReady[game.local] || game.session.is_watching(game); + const char* key = isReady ? 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) { @@ -882,23 +949,23 @@ void Ui::hotbar_draw(Game& game) 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]; + ItemKind kind = i >= fighter.weaponSlots ? ITEM_TRINKET : ITEM_WEAPON; + int index = kind == ITEM_TRINKET ? i - fighter.weaponSlots : i; + int count = fighter.slots_get(kind); + int item = fighter.loadout_get(kind)[index]; Slot slot = { - .rect = game.menu.slot_rect(isTrinket, index, count), + .rect = game.menu.slot_rect(kind, index, count), .size = game.menu.slot_size_get(count), .item = item, - .level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[index], + .level = fighter.slot_levels_get(kind)[index], .label = index + 1, .isHoverable = true, - .isLit = isTrinket ? item >= 0 : index == fighter.slot, - .isTrinket = isTrinket, - .isTarget = game.menu.target_get(game, isTrinket) == index, + .isLit = kind == ITEM_TRINKET ? item >= 0 : index == fighter.slot, + .kind = kind, + .isTarget = game.menu.target_get(game, kind) == index, }; // In the HUD, slots are see-through until the mouse comes over them. Under the menu they're solid. - float hover = slotHovers[isTrinket][index]; + float hover = slotHovers[kind][index]; game.renderer.layer_begin(); slot_draw(game, slot); game.renderer.layer_end(game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover); @@ -908,7 +975,7 @@ void Ui::hotbar_draw(Game& game) if (hovered.item < 0 || game.menu.isOpen) return; - Tooltip tooltip = tooltip_build(game, hovered.isTrinket, hovered.item, hovered.level); + Tooltip tooltip = tooltip_build(game, hovered.kind, hovered.item, hovered.level); tooltip.slot = hovered.label - 1; tooltip_draw(game, tooltip); } @@ -937,7 +1004,7 @@ void Ui::slot_draw(Game& game, Slot slot) if (slot.item >= 0) { - icon_draw(game, slot.isTrinket, slot.item, + icon_draw(game, slot.kind, slot.item, {.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}}, slot.size); @@ -967,7 +1034,7 @@ constexpr int LUNGE_ROW = 8; // 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) + AttackVariant variant) { char number[STAT_LENGTH]; text[0] = '\0'; @@ -997,7 +1064,7 @@ static void stat_format(char* text, int size, int row, const AttackSummary& summ break; case 2: number_format(number, sizeof(number), summary.every); - SDL_snprintf(text, size, isCharged ? "%ss charge" : "%ss", number); + SDL_snprintf(text, size, variant == VARIANT_CHARGED ? "%ss charge" : "%ss", number); break; case 3: if (std::isinf(summary.range)) @@ -1035,16 +1102,17 @@ static void stat_format(char* text, int size, int row, const AttackSummary& summ // how many out at once, spread, lunge), always in that order, for its attack and its charged attack as this fighter // would fire them. A weapon that only pushes its fighter back calls its lunge Recoil. 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) +void Ui::stats_table_build(Fighter& fighter, int weapon, int level, TooltipMode mode, Tooltip& tooltip) { + bool isPreview = mode == TOOLTIP_NEXT_LEVEL; constexpr const char* LABELS[] = {"Fires", "Damage", "Every", "Range", "Blast", "Speed", "Out at once", "Spread", "Lunge"}; const WeaponDef& def = WEAPON_DEFS[weapon]; - AttackSummary summaries[2] = {attack_summary_get(fighter, weapon, level, false), - attack_summary_get(fighter, weapon, level, true)}; + AttackSummary summaries[2] = {attack_summary_get(fighter, weapon, level, VARIANT_NORMAL), + attack_summary_get(fighter, weapon, level, VARIANT_CHARGED)}; AttackSummary nexts[2] = {}; for (int column = 0; column < 2 && isPreview; column++) - nexts[column] = attack_summary_get(fighter, weapon, level + 1, column == 1); + nexts[column] = attack_summary_get(fighter, weapon, level + 1, (AttackVariant)column); bool isRecoil = summaries[0].lunge <= 0 && summaries[1].lunge <= 0; tooltip.rowCount = 0; for (int row = 0; row < (int)std::size(LABELS); row++) @@ -1054,11 +1122,11 @@ void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPrevi bool isShown = false; for (int column = 0; column < 2; column++) { - bool isCharged = column == 1; - const AttackDef& attack = isCharged ? def.charged : def.normal; + AttackVariant variant = (AttackVariant)column; + const AttackDef& attack = variant == VARIANT_CHARGED ? def.charged : def.normal; AttackSummary& summary = summaries[column]; char* value = stat.values[column]; - stat_format(value, STAT_LENGTH, row, summary, attack, isCharged); + stat_format(value, STAT_LENGTH, row, summary, attack, variant); bool changes[] = {summary.isPiecesChanged, summary.isDamageChanged, summary.isEveryChanged, @@ -1072,7 +1140,7 @@ void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPrevi if (isPreview && *value) { char next[STAT_LENGTH]; - stat_format(next, sizeof(next), row, nexts[column], attack, isCharged); + stat_format(next, sizeof(next), row, nexts[column], attack, variant); if (SDL_strcmp(next, value) != 0) SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " → %s", next); } @@ -1082,24 +1150,39 @@ void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPrevi } } +// How many value columns a stats table has (a weapon's attack and charged, a trinket's one), and how many header lines +// (a weapon's column names, which a trinket goes without). +static int table_columns_get(Tooltip& tooltip) +{ + return tooltip.kind == ITEM_WEAPON ? 2 : 1; +} + +static int table_header_get(Tooltip& tooltip) +{ + return tooltip.kind == ITEM_WEAPON ? 1 : 0; +} + // 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}; + int count = table_columns_get(tooltip); + float widths[3] = {}; + for (int column = 0; column < count && tooltip.kind == ITEM_WEAPON; column++) + widths[column + 1] = text.size_get(TABLE_HEADERS[column], 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++) + for (int column = 0; column < count; 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{}; + int lines = tooltip.rowCount + table_header_get(tooltip); + return tooltip.rowCount > 0 ? Vec2{columns[count] + widths[count], lines * 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 @@ -1117,94 +1200,31 @@ void Ui::table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, cons } 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++) + for (int column = 0; column < table_columns_get(tooltip); 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 an arrow, 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, previewing the next level's where asked (the // catalog). Trinkets say how many copies are equipped, and when they stack. -Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, bool isPreview) +Tooltip Ui::tooltip_build(Game& game, ItemKind kind, int item, int level, TooltipMode mode) { + // The last level has no next to show. + mode = level < LEVEL_COUNT - 1 ? mode : TOOLTIP_PLAIN; + bool isPreview = mode == TOOLTIP_NEXT_LEVEL; Tooltip tooltip; - tooltip.isTrinket = isTrinket; + tooltip.kind = kind; tooltip.item = item; Fighter& fighter = game.local_get(); - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; - int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = fighter.loadout_get(kind); + int slots = fighter.slots_get(kind); tooltip.slot = std::find(loadout, loadout + slots, item) - loadout; if (tooltip.slot >= slots) tooltip.slot = -1; - if (isTrinket) + if (kind) { const TrinketDef& def = TRINKET_DEFS[item]; int copies = fighter.trinket_count(item); @@ -1214,18 +1234,13 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, bool else SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); tooltip.name = def.name; - // Previewed (in the catalog), the effects show what the next level changes after an arrow, as stats do. - isPreview &= level < LEVEL_COUNT - 1; - char effects[TOOLTIP_LENGTH]; + // Previewed (in the catalog), the stats show what the next level changes after an arrow, as weapons' do. if (isPreview) - effects_preview_get(game, def.effects[level], def.effects[level + 1], effects, sizeof(effects)); + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d → Level %d)\n%s", level + 1, level + 2, + equipped); 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.stats, sizeof(tooltip.stats), "(Level %d)\n%s", level + 1, equipped); + trinket_table_build(item, level, mode, tooltip); 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); @@ -1235,11 +1250,10 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, bool const WeaponDef& def = WEAPON_DEFS[item]; tooltip.name = def.name; // Previewed (in the catalog), values that change at the next level show it after an arrow. - isPreview &= 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); + stats_table_build(fighter, item, level, mode, tooltip); + 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; @@ -1249,7 +1263,7 @@ 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 stats = *tooltip.stats ? text.size_get(tooltip.stats, font) : Vec2{}; 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]; @@ -1258,23 +1272,27 @@ Vec2 Ui::tooltip_size(Game& game, Tooltip& tooltip, Font font) 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. +// Stacks the name (in the tooltip's own colour, if it has one), 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); + Color title = tooltip.border.a > 0 ? tooltip.border : tint; + text.draw(game.renderer, tooltip.name, {.color = color_fade(title, 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; + if (*tooltip.stats) + text.draw(game.renderer, tooltip.stats, {.color = color_fade(shade, alpha), .position = cursor}, font); + cursor.y += *tooltip.stats ? text.size_get(tooltip.stats, font).y : 0; 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); + int header = table_header_get(tooltip); + for (int row = -header; row < tooltip.rowCount && table.y > 0; row++) + table_row_draw(game, tooltip, row, + {cursor.x, cursor.y + TABLE_SPACE + (row + header) * 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); @@ -1295,7 +1313,7 @@ void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip) tooltip_icon_draw( game, tooltip, {.color = tint, - .position = {area.x + area.w - (isScrolling ? SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, + .position = {area.x + area.w - (isScrolling ? PAGE_SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, iconY + ICON_SIZE / 2}}); auto lines = [&](const char* text, Color color) { for (const char* start = text;;) @@ -1323,7 +1341,7 @@ void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip) float y = 0; if (tooltip.rowCount > 0) row_get(page, TABLE_SPACE, y); - for (int row = -1; row < tooltip.rowCount && tooltip.rowCount > 0; row++) + for (int row = -table_header_get(tooltip); 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); @@ -1338,7 +1356,8 @@ 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) + bool isIcon = tooltip.item >= 0 || tooltip.fashion > 0; + if (isIcon) 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, @@ -1346,20 +1365,27 @@ void Ui::tooltip_draw(Game& game, Tooltip& tooltip) 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); + panel_draw(game.renderer, panel, tooltip.border.a > 0 ? tooltip.border : tint); tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}); - if (tooltip.item >= 0) + if (isIcon) tooltip_icon_draw(game, tooltip, {.color = tint, .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); } -// A tooltip's item icon, centred on the placement, with the hotbar slot it's in numbered over its top left corner as on -// the hotbar. +// A tooltip's item icon (or fashion item's art), centred on the placement, with the hotbar slot it's in numbered over +// its top left corner as on the hotbar. void Ui::tooltip_icon_draw(Game& game, Tooltip& tooltip, Draw placement) { - icon_draw(game, tooltip.isTrinket, tooltip.item, placement); + // A fashion item's art sits centred in its cell, which is centred on the placement. + float half = FASHION_CELL / 2; + if (tooltip.fashion > 0) + game.sprites.fashion_icon_draw( + game.renderer, tooltip.fashion, + {placement.position.x - half, placement.position.y - half, FASHION_CELL, FASHION_CELL}, placement.color); + else + icon_draw(game, tooltip.kind, tooltip.item, placement); if (tooltip.slot < 0) return; char label[] = {char('1' + tooltip.slot), 0}; @@ -1414,6 +1440,43 @@ void Ui::label_draw(Game& game, const char* text, Vec2 position) {.color = widget_color(false), .position = {position.x, position.y + TEXT_INSET}}); } +// Notes what the option in box does, to show (see hint_draw) while the mouse is over it, even greyed out. +void Ui::hint_set(Game& game, Rect box, const char* text) +{ + if (isWidgetLive && !isFocusShown && is_point_in_rect(game.input.mouse, box)) + hint = text; +} + +// The hovered option's hint, if any, as a plain tooltip, cleared for the next frame. +void Ui::hint_draw(Game& game) +{ + if (!hint) + return; + Tooltip tooltip; + tooltip.name = hint; + tooltip_draw(game, tooltip); + hint = nullptr; +} + +// A link to a page: lit, underlined under the mouse (or focus), and clicking it opens the page in the browser (a new +// tab, in the browser's build). +void Ui::link_draw(Game& game, Vec2 position, const char* label, const char* url) +{ + Vec2 size = game.text.size_get(label); + Vec2 corner = {position.x, position.y + TEXT_INSET}; + Rect box = {position.x, position.y, size.x, size.y}; + bool isFocused = focus_add(url, 0, box); + bool isMouse = is_hovered(game, box); + if ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) + { + game.audio.play(SOUND_SELECT); + SDL_OpenURL(url); + } + game.text.draw(game.renderer, label, {.color = widget_color(true), .position = corner}); + if (isMouse || isFocused) + game.renderer.rect_draw({corner.x, corner.y + size.y, size.x, 1}, widget_color(true)); +} + // 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) { @@ -1487,9 +1550,9 @@ bool Ui::button_draw(Game& game, Rect box, const char* label, const void* id) 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) +// A slot framed like a hotbar slot, lit while value is its option (or it's under the mouse), clicking it picking that +// option, like a radio. What it holds is drawn over it. +bool Ui::choice_draw(Game& game, Rect box, int& value, int option) { bool isFocused = focus_add(&value, option, box); bool isMouse = is_hovered(game, box); @@ -1499,10 +1562,15 @@ bool Ui::swatch_draw(Game& game, Rect box, Color color, int& value, int option) 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; + panel_draw(game.renderer, box, widget_color(value == option || isMouse || isFocused), -1, PANEL_BORDER); + return isChanged; +} + +// A colour to pick (see choice_draw), filling its slot, more of it once picked. +bool Ui::swatch_draw(Game& game, Rect box, Color color, int& value, int option) +{ + bool isChanged = choice_draw(game, box, value, option); + float inset = value == option ? 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; @@ -1734,24 +1802,46 @@ bool Ui::fold_draw(Game& game, Rect row, const char* label, bool isOpen) 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) +// A fighter walking on the spot at the right of an area, in a colour and wearing worn, its name over it and a caption +// under it (a line each). +void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color color, const char* caption, int worn) { + // The name, the fighter at its tallest in what it wears, and the caption, stacked and centred in the column (by + // their most, so it holds still as it walks). 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}}); + float body = std::max(game.sprites.player.rise_get(PREVIEW_ANIMATION), game.sprites.fashion_height_get(worn)); + float captionHeight = caption && *caption ? game.text.size_get(caption).y : 0; + float height = nameSize.y + PREVIEW_NAME_GAP + body * PREVIEW_SCALE + PREVIEW_CAPTION_GAP + captionHeight; + float top = std::round(area.y + (area.h - height) / 2); + game.text.draw(game.renderer, name, {.color = color, .position = {std::round(centre - nameSize.x / 2), top}}); + float feet = std::round(top + nameSize.y + PREVIEW_NAME_GAP + body * PREVIEW_SCALE); + player_draw(game, {centre, feet}, color, PREVIEW_SCALE, worn); + float y = feet + PREVIEW_CAPTION_GAP; + for (const char* start = caption; start && *start;) + { + const char* end = SDL_strchr(start, '\n'); + char line[CAPTION_LENGTH]; + SDL_strlcpy(line, start, std::min(end ? end - start + 1 : sizeof(line), sizeof(line))); + Vec2 size = game.text.size_get(line); + game.text.draw(game.renderer, line, {.color = shade, .position = {std::round(centre - size.x / 2), y}}); + y += size.y; + start = end ? end + 1 : nullptr; + } +} + +// A player walking in place (slowly) from their feet, for previews. +void Ui::player_draw(Game& game, Vec2 feet, Color color, float scale, int worn) +{ Anm2& player = game.sprites.player; Anm2Animation* preview = player.animation_get(PREVIEW_ANIMATION); float frame = preview ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)preview->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}}); + Pose pose = {.art = &player, + .animation = PREVIEW_ANIMATION, + .placement = {.color = color, .position = feet, .scale = {scale, scale}}, + .frame = frame}; + pose.draw(game.renderer); + game.sprites.fashion_draw(game.renderer, pose, worn); } // A connection's quality: signal bars (more lit the quicker it is, green to red) and its round trip in milliseconds, a @@ -1921,7 +2011,7 @@ 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}; + Rect track = {page.area.x + page.area.w - PAGE_SCROLL_WIDTH, page.area.y, PAGE_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)); @@ -1976,12 +2066,12 @@ void Ui::update(Game& game) 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++) + for (ItemKind kind : ITEM_KINDS) { - int count = kind == 1 ? local.trinketSlots : local.weaponSlots; + int count = local.slots_get(kind); 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))); + slot < count && is_point_in_rect(mouse, game.menu.slot_rect(kind, slot, count))); } RivalLayout rivals = rivals_layout(game); for (int i = 0; i < MAX_FIGHTERS; i++) @@ -1998,10 +2088,10 @@ void Ui::update(Game& game) hover_step(statusHover, is_point_in_rect(mouse, statusRect)); // 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. + // the first one's), and trinkets with a cooldown of their own (each copy its own, see trinket_cooldown_get). 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); + float gloves = fighter.stat_get(STAT_ATTACK_RATE); for (int slot = 0; slot < SLOT_MAX; slot++) { float left = 0; @@ -2015,10 +2105,10 @@ void Ui::update(Game& game) int trinket = fighter.trinketLoadout[slot]; left = 0; - if (slot < fighter.trinketSlots && trinket >= 0 && - std::find(fighter.trinketLoadout, fighter.trinketLoadout + slot, trinket) == fighter.trinketLoadout + slot) + if (slot < fighter.trinketSlots && trinket >= 0) { - left = fighter.trinket_cooldown_get(trinket); + int copy = std::count(fighter.trinketLoadout, fighter.trinketLoadout + slot, trinket); + left = fighter.trinket_cooldown_get(trinket, copy); trinketBars[slot].item = left > 0 ? trinket : trinketBars[slot].item; } bar_fade(trinketBars[slot], left); @@ -2034,29 +2124,28 @@ void Ui::update(Game& game) void Ui::cooldowns_draw(Game& game) { Fighter& fighter = game.local_get(); - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; - IconSize size = game.menu.slot_size_get(isTrinket ? fighter.trinketSlots : fighter.weaponSlots); + IconSize size = game.menu.slot_size_get(fighter.slots_get(kind)); float height = COOLDOWN_BAR_HEIGHTS[size]; float icon = ICON_PIXELS[size]; float width = COOLDOWN_SLOTS * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size]; float spacing = std::max(icon, height) + COOLDOWN_ROW_GAP; float start = HOTBAR_MARGIN + SLOT_SIZES[size] + COOLDOWN_GAP + std::ceil(std::max(0.0f, (icon - height) / 2)); - CooldownBar* bars = isTrinket ? trinketBars : weaponBars; + CooldownBar* bars = kind == ITEM_TRINKET ? trinketBars : weaponBars; 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); - Rect rect = {HOTBAR_MARGIN, isTrinket ? CANVAS_HEIGHT - start - height - step : start + step, width, - height}; + Rect rect = {HOTBAR_MARGIN, kind == ITEM_TRINKET ? CANVAS_HEIGHT - start - height - step : start + step, + width, height}; float middle = rect.y + height / 2; bar.rect = {rect.x, middle - spacing / 2, width + COOLDOWN_GAP + icon, spacing}; game.renderer.layer_begin(); bar_draw(game.renderer, rect, bar.fill, tint, SLOT_STROKES[size]); - icon_draw(game, isTrinket, bar.item, + icon_draw(game, kind, bar.item, {.color = tint, .position = {std::round(rect.x + width + COOLDOWN_GAP + icon / 2), middle}}, size); game.renderer.layer_end(bar.fade * hover_alpha_get(bar.hover)); @@ -2065,12 +2154,11 @@ void Ui::cooldowns_draw(Game& game) } // 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) +void Ui::icon_draw(Game& game, ItemKind kind, int item, Draw placement, IconSize size) { char name[TOOLTIP_LENGTH]; - SDL_snprintf(name, sizeof(name), "%s%s", isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, - ICON_SUFFIXES[size]); - (isTrinket ? game.sprites.trinkets : game.sprites.icons).draw(game.renderer, name, 0, placement); + SDL_snprintf(name, sizeof(name), "%s%s", item_name_get(kind, item), ICON_SUFFIXES[size]); + (kind == ITEM_TRINKET ? game.sprites.trinkets : game.sprites.icons).draw(game.renderer, 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 @@ -2106,8 +2194,8 @@ void Ui::offscreen_draw(Game& game, Vec2 camera) } // Each of this screen's fighters' crosshairs in its colour, filling in and shaking with its weapon's charge and -// cooldown: 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. +// cooldown, and darkened through the cooldown: 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; @@ -2120,10 +2208,11 @@ void Ui::crosshairs_draw(Game& game, Vec2 camera) bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) || (i == game.local && game.menu.isOpen); Fighter& fighter = game.sim.fighters[i]; - Vec2 aim = (isMouse ? input.mouse : fighter.controls.cursor - camera) + - game.scene.weapon_shake_get(fighter, fighter.weapon_key(fighter.slot)); + int held = fighter.weapon_key(fighter.slot); + Vec2 aim = + (isMouse ? input.mouse : fighter.controls.cursor - camera) + game.scene.weapon_shake_get(fighter, held); game.sprites.ui.draw(game.renderer, "Crosshair", 0, - {.color = game.color_get(i), + {.color = game.scene.cooldown_tint_get(fighter, held, game.color_get(i)), .position = {std::floor(aim.x), std::floor(aim.y)}, .flash = game.scene.charge_fill_get(fighter)}); } @@ -2163,19 +2252,18 @@ void Ui::panels_draw(Game& game) bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.sim.match.health_get(), color); } int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP; - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; - int slots = std::min(fit, isTrinket ? fighter.trinketSlots : fighter.weaponSlots); - float rowY = y + LINE_HEIGHT + kind * MINI_STEP; + int slots = std::min(fit, fighter.slots_get(kind)); + float rowY = y + LINE_HEIGHT + (int)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; + int item = fighter.loadout_get(kind)[slot]; + bool isLit = kind == ITEM_TRINKET ? 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, + icon_draw(game, kind, item, {.color = isLit ? color : dim, .position = {box.x + box.w / 2, box.y + box.h / 2}, .scale = {MINI_ICON_SCALE, MINI_ICON_SCALE}}); @@ -2190,7 +2278,7 @@ static bool is_spectating(Game& game) { MatchState state = (MatchState)game.sim.match.state; // Watching a queued game from the lobby, it's whoever's still in it. - if (game.session.is_watching()) + if (game.session.is_watching(game)) { for (int i = 0; i < game.sim.fighterCount; i++) if (game.sim.fighters[i].isAlive) @@ -2211,7 +2299,7 @@ static bool is_spectating(Game& game) void Ui::spectate_update(Game& game) { Input& input = game.input; - bool isWatching = game.session.is_watching(); + bool isWatching = game.session.is_watching(game); int device = isWatching ? DEVICE_KEYBOARD : game.devices[game.local]; bool held[3] = {}; if (device != DEVICE_NONE) @@ -2262,19 +2350,14 @@ 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)); + text_center_draw(game, text, y, 1, game.color_get(spectated, TINT_PLAIN)); 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" : ""); + input.control_name_get(i == 0 ? CONTROL_LEFT : CONTROL_RIGHT, keys[i], sizeof(keys[i])); + SDL_snprintf(text, sizeof(text), "%s / %s: Switch", keys[0], keys[1]); text_center_draw(game, text, y + LINE_HEIGHT, 1, shade); } @@ -2297,7 +2380,7 @@ RivalLayout Ui::rivals_layout(Game& game) int slots[2] = {}; for (int i = 0; i < game.sim.fighterCount; i++) { - if (i == game.local && !game.session.is_watching()) + if (i == game.local && !game.session.is_watching(game)) continue; layout.fighters[layout.count++] = i; slots[0] = std::max(slots[0], game.sim.fighters[i].weaponSlots); @@ -2341,7 +2424,7 @@ RivalLayout Ui::rivals_layout(Game& game) for (int rows = rowsMin; rows <= rowsMax && !isFit; rows++) { layout.rows = layout.isIconShown ? rows : 0; - for (int kind = 0; kind < 2; kind++) + for (ItemKind kind : ITEM_KINDS) layout.columns[kind] = (slots[kind] + rows - 1) / rows; float icons = layout.isIconShown ? (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP @@ -2452,23 +2535,22 @@ void Ui::rivals_draw(Game& game) } 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++) + for (ItemKind kind : ITEM_KINDS) { - bool isTrinket = kind == 1; - int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; - int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int slots = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); for (int slot = 0; slot < slots; slot++) { int item = loadout[slot]; if (item < 0) continue; - bool isLit = isTrinket || slot == fighter.slot; + bool isLit = kind == ITEM_TRINKET || slot == fighter.slot; Vec2 centre = {x + slot % layout.columns[kind] * cell + iconSize / 2, y + slot / layout.columns[kind] * cell + iconSize / 2}; - icon_draw(game, isTrinket, item, {.color = isLit ? color : dim, .position = centre}, layout.size); + icon_draw(game, kind, item, {.color = isLit ? color : dim, .position = centre}, layout.size); } x += layout.columns[kind] * cell; - if (kind == 0) + if (kind == ITEM_WEAPON) { renderer.rect_draw({x, y, RIVAL_DIVIDER, layout.rows * cell - RIVAL_ICON_GAP}, dim); x += RIVAL_DIVIDER + RIVAL_ICON_GAP; @@ -2510,12 +2592,13 @@ void Ui::pick_panel_draw(Game& game, int index, Rect panel) Fighter& fighter = game.sim.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; + ItemKind& kind = pickKinds[index]; + bool isBoth = match.isPicks[ITEM_WEAPON] && match.isPicks[ITEM_TRINKET]; + kind = match.isPicks[ITEM_TRINKET] && (kind == ITEM_TRINKET || !match.isPicks[ITEM_WEAPON]) ? ITEM_TRINKET + : ITEM_WEAPON; if (isBoth && (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) || input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER))) - isTrinket = !isTrinket; + kind = kind == ITEM_TRINKET ? ITEM_WEAPON : ITEM_TRINKET; Color previousTint = tint; Color previousShade = shade; @@ -2523,7 +2606,7 @@ void Ui::pick_panel_draw(Game& game, int index, Rect panel) shade = color_shade(tint, SECONDARY_SHADE); panel_draw(game.renderer, panel, tint); - int items = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + int items = item_count_get(kind); float step = SLOT_SIZE + SLOT_GAP; int columns = std::max(1, (int)((panel.w - PICK_PADDING * 2 + SLOT_GAP) / step)); int& cursor = pickCursors[index]; @@ -2531,16 +2614,16 @@ void Ui::pick_panel_draw(Game& game, int index, Rect panel) 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; + int slots = fighter.slots_get(kind); + int* loadout = fighter.loadout_get(kind); 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); + const char* heading = isBoth ? (kind == ITEM_TRINKET ? "Trinkets (Bumpers)" : "Weapons (Bumpers)") + : (kind == ITEM_TRINKET ? "Trinkets" : "Weapons"); + const char* item = item_name_get(kind, cursor); + SDL_snprintf(text, sizeof(text), "%s - %s: %s", name_get(game, index), heading, 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; @@ -2558,11 +2641,10 @@ void Ui::pick_panel_draw(Game& game, int index, Rect panel) if (input.isClicked) { int empty = std::find(loadout, loadout + slots, -1) - loadout; - pick_send(game, index, isTrinket, std::min(empty, slots - 1), i); + pick_send(game, index, kind, std::min(empty, slots - 1), i); } } - slot_draw(game, - {.rect = box, .item = i, .isHoverable = isKeyboard, .isLit = i == cursor, .isTrinket = isTrinket}); + slot_draw(game, {.rect = box, .item = i, .isHoverable = isKeyboard, .isLit = i == cursor, .kind = kind}); } y += (items + columns - 1) / columns * step + PICK_PADDING; @@ -2571,37 +2653,37 @@ void Ui::pick_panel_draw(Game& game, int index, Rect panel) 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); + pick_send(game, index, kind, slot, cursor); if (isHovered && input.isRightClicked) - pick_send(game, index, isTrinket, slot, -1); + pick_send(game, index, kind, slot, -1); slot_draw(game, {.rect = box, .item = loadout[slot], .label = slot + 1, .isHoverable = isKeyboard, .isLit = loadout[slot] >= 0, - .isTrinket = isTrinket}); + .kind = kind}); } if (isKeyboard && input.slotPressed >= 0 && input.slotPressed < slots) - pick_send(game, index, isTrinket, input.slotPressed, cursor); + pick_send(game, index, kind, 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); + pick_send(game, index, kind, 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); + pick_send(game, index, kind, 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) +void Ui::pick_send(Game& game, int fighter, ItemKind kind, int slot, int item) { - game.input.edit_send(game.devices[fighter], pick_encode(isTrinket, slot, item)); + game.input.edit_send(game.devices[fighter], pick_encode(kind, slot, item)); game.audio.play(item < 0 ? SOUND_UNEQUIP : SOUND_EQUIP); } @@ -2789,7 +2871,7 @@ void Ui::queues_draw(Game& game) Queue& queue = match.queues[q]; if (!queue.isActive) continue; - Color color = game.color_get(q, true); + Color color = game.color_get(q, TINT_PLAIN); Rect area = queue.area; int members[MAX_FIGHTERS]; int count = match.queue_members_get(game.sim, q, members); @@ -2807,7 +2889,7 @@ void Ui::queues_draw(Game& game) Fighter& queuer = game.sim.fighters[q]; Rect box = queuer.body.box; - Vec2 icon = {std::round(box.x + box.w / 2), std::round(box.y - QUEUE_ICON_RISE + bob)}; + Vec2 icon = {std::round(box.x + box.w / 2), std::round(game.scene.top_get(queuer) - 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}); @@ -2834,10 +2916,8 @@ void Ui::queues_draw(Game& game) 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)); + input.control_name_get(CONTROL_READY, 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, @@ -2857,6 +2937,6 @@ void Ui::queue_tooltip_draw(Game& game) 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)); + panel_draw(game.renderer, panel, game.color_get(hoveredQueue, TINT_PLAIN)); game_summary_draw(game, rules, {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}); } diff --git a/src/ui.h b/src/ui.h index d303441..e61c762 100644 --- a/src/ui.h +++ b/src/ui.h @@ -14,6 +14,8 @@ constexpr float HOTBAR_MARGIN = 8; constexpr float PANEL_BORDER = 2; constexpr float SLOT_GAP = 4; constexpr float SLOT_SIZE = 28; +// How wide a page's scroll bar is. +constexpr float PAGE_SCROLL_WIDTH = 4; constexpr float WIDGET_GAP = 6; constexpr float WIDGET_HEIGHT = 14; @@ -84,6 +86,14 @@ struct CountdownInfo char key[64] = {}; }; +// What an item's tooltip shows: the item as it is, or (in the catalog, where it levels) what its next level changes +// too, after arrows. +enum TooltipMode +{ + TOOLTIP_PLAIN, + TOOLTIP_NEXT_LEVEL +}; + struct Tooltip { const char* name = ""; @@ -93,9 +103,13 @@ struct Tooltip char description[TOOLTIP_LENGTH] = {}; char flavor[TOOLTIP_LENGTH] = {}; // The item it's about, whose icon it shows top right, numbered with the hotbar slot it's in (-1 for none). - bool isTrinket = false; + ItemKind kind = ITEM_WEAPON; int item = -1; int slot = -1; + // A fashion item it's about instead (none for 0), whose art is its icon. + int fashion = 0; + // The window's colour, when it isn't the interface's own (a colour choice's tooltip wears that colour). + Color border = CLEAR; }; // How much of each item the round's loadout overview shows, from most to least: everything, everything in the small @@ -167,7 +181,7 @@ struct Slot int label = 0; bool isHoverable = false; bool isLit = false; - bool isTrinket = false; + ItemKind kind = ITEM_WEAPON; // Whether it's the menu's target slot, marked by a bar under it. bool isTarget = false; }; @@ -192,14 +206,17 @@ public: int adjust = 0; bool isConfirmed = false; bool isFocusShown = false; + // What the option under the mouse does, shown in a tooltip once the menu's drawn (see hint_set). + const char* hint = nullptr; void init(Game& game); void update(Game& game); void draw(Game& game); void slot_draw(Game& game, Slot slot); void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1, float stroke = PANEL_BORDER); - Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, bool isPreview = false); - void stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip); + Tooltip tooltip_build(Game& game, ItemKind kind, int item, int level, TooltipMode mode = TOOLTIP_PLAIN); + void tooltip_draw(Game& game, Tooltip& tooltip); + void stats_table_build(Fighter& fighter, int weapon, int level, TooltipMode mode, 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); @@ -210,17 +227,23 @@ public: 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 player_draw(Game& game, Vec2 feet, Color color, float scale, int worn); + void icon_draw(Game& game, ItemKind kind, int item, Draw placement, IconSize size = ICON_FULL); 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 choice_draw(Game& game, Rect box, int& value, int option); 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); + void link_draw(Game& game, Vec2 position, const char* label, const char* url); + void hint_set(Game& game, Rect box, const char* text); + void hint_draw(Game& game); 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); + void fighter_preview_draw(Game& game, Rect area, const char* name, Color tint, const char* caption, int worn); float ping_draw(Game& game, Vec2 corner, float ping, float alpha); void ping_bars_draw(Game& game, Vec2 corner, float ping, float alpha); void queues_draw(Game& game); @@ -255,7 +278,8 @@ private: // 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] = {}; + // The kind each local player is choosing on their pick panel. + ItemKind pickKinds[MAX_FIGHTERS] = {}; void* active = nullptr; FocusTarget targets[2][FOCUS_TARGETS_MAX]; Rect focusRect; @@ -274,7 +298,7 @@ private: // How far the mouse being over each HUD element has changed it, from 0 to 1: hotbar slots (weapons, then trinkets) // come up from see-through to solid, and the rest (other fighters' windows, the readouts) fade. Rect statusRect; - float slotHovers[2][SLOT_MAX] = {}; + float slotHovers[ITEM_KIND_COUNT][SLOT_MAX] = {}; float rivalHovers[MAX_FIGHTERS] = {}; float statusHover = 0; float loadoutTop = 0; @@ -292,12 +316,11 @@ private: 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 countdown_aside_draw(Game& game); 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 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); @@ -306,9 +329,8 @@ private: 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 pick_send(Game& game, int fighter, ItemKind kind, 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, float cut = CHAMFER); Color widget_color(bool isLit); bool is_hovered(Game& game, Rect box); diff --git a/src/weapon.cpp b/src/weapon.cpp index 14df0b4..cb4f58a 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -5,115 +5,109 @@ constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; -constexpr int HEAVY_DAMAGE = 40; -constexpr int HOOK_SUBSTEPS = 4; constexpr int TESLA_LINKS = 5; constexpr float TESLA_INTERVAL = 0.2f; constexpr float TESLA_RADIUS = 2; -constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220}; constexpr float DEGREE = PI / 180; constexpr float ARROW_LINGER = 2.5f; -constexpr float ARROW_RADIUS = 6; -constexpr float BEAM_RADIUS = 4; -constexpr float BOOMERANG_LIFETIME = 6; -constexpr float BOOMERANG_RADIUS = 9; 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 DOUBLER_DELAY = 0.15f; constexpr float FALLOFF_FLOOR = 0.1f; -constexpr float FALLOFF_RANGE = 600; +// How far along a beam its damage bottoms out. +constexpr float BEAM_FADE_REACH = 540; constexpr float FILL_STRENGTH = 2; constexpr float TRAJECTORY_REACH = 400; constexpr float TRAJECTORY_STEP = 4; constexpr float FIRE_SHAKE = 0.5f; -constexpr float FIREBALL_RADIUS = 5; -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_RADIUS = 5; -constexpr float FLAME_RISE = 100; -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 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 MUZZLE_DISTANCE = 18; -constexpr float POKE_THRUST = 0.35f; constexpr float PROJECTILE_STEP = 8; constexpr float RAY_STEP = 2; constexpr float RECOIL_CURVE = 0.75f; -constexpr float ROCKET_LIFETIME = 3; -constexpr float ROCKET_RADIUS = 4; constexpr float ROCKET_TAIL = 7; -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.003f; -constexpr float SLAM_RATIO = 0.75f; +constexpr float SLAM_RATIO = 1.05f; 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_GLIDE = 14; -constexpr float WISP_HOME_MIN = 10; +// How near its fighter's cursor a homing shot stops turning toward it. +constexpr float HOMING_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 TELEPORT_PROBE = 4; constexpr float TELEPORT_STEP = 2; 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 float WHIP_COIL_GAP = 3; +constexpr int WHIP_COIL_STEPS = 48; constexpr float WHIP_PULL = 220; constexpr float WOBBLE_IMPULSE = 0.15f; -constexpr Vec2 HAMMER_HEAD = {20, 0}; -constexpr float SLUG_RADIUS = 2; -constexpr Burst EMBER_BURST = {.gravity = -150, .life = 0.35f, .size = 2, .speed = 60, .spread = 0.6f, .count = 1}; +// Vacuum: how fast its pull speeds fighters and shots up toward it, how much of an airblast's knock is left at the edge +// of its reach, and how much of it shoves shots. +constexpr float VACUUM_PULL_RATE = 900; +// How many targets an attack can remember striking (one bit each). +constexpr int STRUCK_MAX = 32; +constexpr float GUST_SHOT_SHARE = 0.16f; +// How much of their spawned life (and range, and falloff) the owner's shots in a Vacuum's cone get back: each time its +// suck nibbles, and all of it from an airblast. +constexpr float VACUUM_RESTORE = 0.1f; +constexpr float GUST_RESTORE = 1; +// How long a Vacuum's suck takes to spin up to its full reach, and to wind back down once let go. +constexpr float VACUUM_SPIN_UP = 0.25f; +constexpr float VACUUM_SPIN_DOWN = 0.2f; +// Grenades: their body's size, how much of a hard knock off a surface they keep, how hard a knock has to be to bounce, +// rolling friction, and the fuse each Ricochet adds. +// How much of a Vacuum's drift a resting mine or a boomerang keeps each tick. +// How slow an arming shot must be going to come to rest. +constexpr float REST_SPEED = 40; +// How fast a shot flies home, against the speed it left at. +constexpr float RETURN_SHARE = 0.5f; +// How hard a bouncing shot must hit a surface to bounce off it. +constexpr float SHOT_BOUNCE_MIN = 60; +// How much of its blast a grenade deals to whoever it clonks on the way. +constexpr float GRENADE_CONTACT_SHARE = 0.2f; +// The double donk: how soon after a marking hit (a grenade's clonk) a blast from the same weapon must land, how much +// harder it hits, and how long its blast lingers, flashing. +constexpr float DONK_WINDOW = 0.25f; +constexpr float DONK_DAMAGE = 3; +constexpr float DONK_FLASH = 0.7f; +// How fast a bouncing shot turns as it rolls. +constexpr float ROLL_SPIN = 3; +// Bolts: their size, gravity (heavier when charged), how far one crawls in a step, how many turns it tries a step +// (from into its surface round to away from it), how far it reaches for a surface on landing, and how long one lasts at +// most. +constexpr float CRAWL_STEP = 2; +constexpr int CRAWL_TURNS = 12; +constexpr float CRAWL_SNAP = 10; 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}; @@ -135,17 +129,17 @@ static bool is_caught(Fighter& fighter, Attack& attack) 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(Sim& sim, Fighter& fighter, Attack& attack) +// Moves a returning shot one tick toward its fighter's hand at half the speed it left at, 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 shot_return(Sim& sim, Fighter& fighter, Attack& attack) { - Vec2 toHand = fighter.hand_get() - attack.position; + Vec2 toHand = fighter.hand_get(attack.hand) - 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); + float travel = std::min(distance, attack.stats.speed * RETURN_SHARE * TICK); int steps = travel / BOOMERANG_STEP + 1; Vec2 step = vec2_normalize(toHand) * (travel / steps); for (int i = 0; i < steps; i++) @@ -203,10 +197,14 @@ static void impact_play(Sim& sim, AttackKind kind) case ATTACK_FLAIL: sim.cues->sound_play(SOUND_FLAIL_LAND); break; + case ATTACK_GRENADE: + sim.cues->sound_play(SOUND_GRENADE_BOUNCE); + break; case ATTACK_MINE: sim.cues->sound_play(SOUND_IMPACT_MINE); break; case ATTACK_PROJECTILE: + case ATTACK_WISP: sim.cues->sound_play(SOUND_IMPACT_BLASTER); break; default: @@ -214,15 +212,49 @@ static void impact_play(Sim& sim, AttackKind kind) } } -// Turns a projectile off the wall it's about to enter. Each ricochet doubles its later damage. +// Spends one of an attack's ricochets on a bounce, adding to its later damage. Flames go quietly, as there are so +// many. +static void ricochet_count(Sim& sim, Attack& attack) +{ + attack.bounces--; + attack.ricochets++; + if (attack.def()->kind == ATTACK_FLAME) + return; + sim.cues->sound_play(SOUND_RICOCHET); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); +} + +// Turns a projectile off the wall it's about to enter, starting its range, lifetime and damage falloff afresh from +// there. static void ricochet(Sim& sim, Attack& attack, Vec2 next) { attack.velocity = reflect_get(sim, attack.position, next, attack.velocity); attack.angle = vec2_angle(attack.velocity); - attack.bounces--; - attack.ricochets++; - sim.cues->sound_play(SOUND_RICOCHET); - sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); + attack.origin = attack.position; + attack.lifeStart = attack.time; + ricochet_count(sim, attack); +} + +// What stops an attack moving through the world: walls and platform tops for most, walls alone for wisps. +using WallCheck = bool (World::*)(Vec2); + +// Moves an attack toward a point in small steps, so a fast one can't skip through a thin wall. It stops on the last +// clear step, writing the first blocked one, and returns whether it reached a wall. +static bool is_wall_reached(Sim& sim, Attack& attack, Vec2 to, Vec2& wall, WallCheck isWall = &World::is_blocking_at) +{ + Vec2 from = attack.position; + int steps = vec2_length(to - from) / PROJECTILE_STEP + 1; + for (int step = 1; step <= steps; step++) + { + Vec2 point = vec2_lerp(from, to, (float)step / steps); + if ((sim.world.*isWall)(point)) + { + wall = point; + return true; + } + attack.position = point; + } + return false; } // Walks the capsule in small steps, writing the first point that touches the rect. @@ -243,31 +275,74 @@ static bool is_capsule_in_rect(Capsule capsule, Rect rect, Vec2& point) return false; } -static bool is_falloff(AttackKind kind) +// Whether an attack's reach and end go by how long it has been out (see lifetime_get), which the Hourglass stretches: +// one timed by a lifetime, a shot with falloff that flies (not an instant, which reaches its range at once), or one on +// a rope or chain (whose length grows with its flight, see Weapons::is_spawned). +static bool is_aging(const AttackDef& def, const AttackStats& stats) { - 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; + return stats.lifetime > 0 || (def.isFalloff && !def.isInstant) || def.isTethered; } -// 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). -float falloff_at(float distance) +// How far an attack reaches as fired, its speed and lifetime grown by its fighter's trinkets (see reach_get). +static float reach_at_get(Attack& attack) { - return std::max(FALLOFF_FLOOR, 1 - distance / FALLOFF_RANGE); + const AttackDef& def = *attack.def(); + if (attack.stats.range > 0 || def.path == PATH_ARC) + return attack.stats.range; + return attack.stats.speed * attack.lifetime * (def.path == PATH_RETURN ? PATH_OUT : 1); } +// Ranged shots lose damage linearly over their fade: full at the muzzle, down to a floor at its end (a fraction of 1) +// and past it. +float falloff_at(float fraction) +{ + return 1 - std::min(1.0f, fraction) * (1 - FALLOFF_FLOOR); +} + +// How far along a beam or a hook's rope its damage bottoms out, or never along anything else. +float falloff_reach_get(Attack& attack) +{ + AttackKind kind = attack.def()->kind; + if (kind == ATTACK_BEAM) + return BEAM_FADE_REACH; + return kind == ATTACK_HOOK ? attack.stats.range : RANGE_INFINITE; +} + +// A shot's falloff goes by how long it has flown. A beam's goes by distance along it to the point, and a hook's by how +// far it reached from where it was thrown. static float falloff_get(Attack& attack, Vec2 point) { AttackKind kind = attack.def()->kind; - if (!is_falloff(kind)) + if (!attack.def()->isFalloff) return 1; - float distance = attack.travelled + (kind == ATTACK_BEAM ? vec2_length(point - attack.position) : 0); + if (kind == ATTACK_BEAM) + return falloff_at(vec2_length(point - attack.position) / falloff_reach_get(attack)); if (kind == ATTACK_HOOK) - distance = vec2_length(attack.position - attack.origin); - if (kind == ATTACK_WISP) - distance = attack.length; - return falloff_at(distance); + return falloff_at(vec2_length(attack.position - attack.origin) / falloff_reach_get(attack)); + return falloff_at(attack.life_get() / attack.fade); +} + +// How long a shot flies before it ends: as long as its base speed takes to cover its range (so a faster one goes +// further, and one that accelerates speeding up as it goes), unless its data gives one (a punch, a flame's linger, a +// grenade's fuse, a mine's wait). +static float lifetime_get(const AttackDef& def, AttackStats& stats) +{ + AttackKind kind = def.kind; + if (stats.lifetime > 0) + return stats.lifetime; + return stats.speed > 0 ? flight_time_get(def, stats.speed, stats.range) : 0; +} + +float flight_time_get(const AttackDef& def, float speed, float distance) +{ + if (def.accelerate <= 0) + return distance / speed; + float top = speed * def.speedup; + float rise = (top - speed) / def.accelerate; + float risen = (speed + top) / 2 * rise; + if (distance > risen) + return rise + (distance - risen) / top; + return (std::sqrt(speed * speed + 2 * def.accelerate * distance) - speed) / def.accelerate; } // How far a rocket, mine or fireball's explosion reaches. It grows with trinkets but not with the attack's own size, so @@ -295,36 +370,32 @@ float fill_get(float strength) return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); } -// 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; -} - -// 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. +// A plain lash rolling out: straight from the handle to its front, then the rest of the whip still wound there in an +// even spiral (its rings WHIP_COIL_GAP apart), curling up and in toward its end, so the coil unwinds as it unrolls. It +// lies straight as it cracks, and coming back it winds up again on the other side, below. 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; + up = attack.progress_get() > PATH_OUT ? up * -1 : up; + float coil = attack.length - vec2_length(attack.tip - attack.position); std::vector points = {attack.position, attack.tip}; - if (radius < 1) + if (coil < 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)); + // An even spiral bends at each point in a circle as wide as the square root of twice the whip left beyond it times + // its ring spacing over a full turn. + float turn = direction.x * up.y - direction.y * up.x > 0 ? 1 : -1; + float spacing = WHIP_COIL_GAP * attack.scale / (2 * PI); + float heading = attack.angle; + float step = coil / WHIP_COIL_STEPS; + Vec2 point = attack.tip; + for (int i = 0; i < WHIP_COIL_STEPS; i++) + { + heading += turn * step / std::sqrt(2 * spacing * (coil - i * step)); + point = point + vec2_polar(heading, step); + points.push_back(point); + } return points; } @@ -397,7 +468,7 @@ static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) // hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip. int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) { - if (attack.def()->kind != ATTACK_WHIP || attack.isCharged) + if (attack.def()->kind != ATTACK_WHIP || attack.is_charged()) { shapes[0] = {attack.position, attack.tip, attack.radius}; return 1; @@ -422,17 +493,18 @@ int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) void Weapons::fire(Sim& sim, Fighter& fighter, float charge) { int held = fighter.weapon_key(fighter.slot); - float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); + float gloves = fighter.stat_get(STAT_ATTACK_RATE); if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held)) { + // Its cooldown only starts when something fires (not a continuous weapon already going, nor one at its limit). bool isSpawned = is_spawned(sim, fighter, {.charge = charge, .weaponSlot = held}); - fighter.cooldowns[held] = fighter.level_get(held).cooldown / gloves; + fighter.cooldowns[held] = isSpawned ? fighter.level_get(held).cooldown / gloves : 0; // 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]]; + float power = TRINKET_DEFS[TRINKET_DOUBLER].levels[fighter.trinketSlotLevels[i]].repeatDamage; fighter.echoes.push_back({charge, DOUBLER_DELAY * ++repeat, power, held}); } } @@ -442,7 +514,7 @@ void Weapons::fire(Sim& sim, Fighter& fighter, float charge) int copies = 0; for (int i = 0; i < fighter.trinketSlots; i++) if (fighter.trinketLoadout[i] == TRINKET_DUAL) - powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].values[fighter.trinketSlotLevels[i]]; + powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].levels[fighter.trinketSlotLevels[i]].offhandDamage; std::sort(powers, powers + copies, std::greater()); int offs[SLOT_MAX]; int count = fighter.off_slots_get(offs); @@ -451,34 +523,13 @@ void Weapons::fire(Sim& sim, Fighter& fighter, float charge) int off = fighter.weapon_key(offs[hand - 1]); if (fighter.cooldowns[off] > 0 || is_busy(fighter, off)) continue; - is_spawned(sim, fighter, - {.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true}); - fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves; + bool isSpawned = is_spawned( + sim, fighter, + {.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true}); + fighter.cooldowns[off] = isSpawned ? fighter.level_get(off).cooldown / gloves : 0; } } -// 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(Sim& sim, Fighter& fighter, Vec2 centre) { @@ -519,11 +570,12 @@ static float push_get(Fighter& fighter, Vec2 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) +// Inheriting shots (which gain the part of the fighter's velocity along the aim, so running away never slows them) +// count only that push, everything else the fighter's whole speed. +static float bonus_get(Fighter& fighter, const AttackDef& def, 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; + float speed = def.isInheriting ? push_get(fighter, aim) : vec2_length(fighter.body.velocity); + return 1 + (SPEED_DAMAGE + fighter.stat_get(STAT_SPEED_DAMAGE)) * speed / SPEED_UNIT; } // How much a fighter's colour fills in from the bonus its held weapon would get firing right now. @@ -532,46 +584,47 @@ 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)); + return fill_get(bonus_get(fighter, WEAPON_DEFS[held].attack_get(variant_get(fighter.charge)), 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. +// and melee reach, the Accelerator speeds up projectiles (and so, like the Hourglass, carries timed shots further), +// 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) +AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, AttackVariant variant) { const WeaponDef& def = WEAPON_DEFS[weapon]; - const AttackDef& attack = isCharged ? def.charged : def.normal; + const AttackDef& attack = variant == VARIANT_CHARGED ? def.charged : def.normal; const LevelDef& levelDef = def.levels[level]; - AttackStats stats = isCharged ? levelDef.charged : levelDef.normal; + AttackStats stats = variant == VARIANT_CHARGED ? levelDef.charged : levelDef.normal; AttackKind kind = attack.kind; bool isTeleport = kind == ATTACK_TELEPORT; - int splits = isTeleport ? 1 : (int)fighter.trinket_get(TRINKET_SPLITTER, 1); + int splits = isTeleport ? 1 : (int)fighter.stat_get(STAT_COPIES); 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; + float embiggen = fighter.stat_get(STAT_DAMAGE); + float enlarger = fighter.stat_get(STAT_ATTACK_SIZE); + float gloves = fighter.stat_get(STAT_ATTACK_RATE); + float drink = fighter.stat_get(STAT_CHARGE_RATE); + float accelerator = attack.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; + float steadiness = 1 - fighter.stat_get(STAT_SPREAD_CUT); + float absorb = fighter.stat_get(STAT_MOVEMENT); 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; + float damage = + splits > 1 ? std::max(1.0f, std::round(stats.damage * fighter.stat_get(STAT_COPY_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.every = variant == VARIANT_CHARGED ? CHARGE_TIME / drink : levelDef.cooldown / gloves; + summary.isEveryChanged = variant == VARIANT_CHARGED ? drink != 1 : gloves != 1; + float flight = is_aging(attack, stats) ? accelerator * fighter.stat_get(STAT_LIFETIME) : 1; + float reach = reach_get(attack.path, stats); + summary.range = reach * (attack.isRigid ? enlarger * splitScale : flight); + summary.isRangeChanged = summary.range != reach; 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.speed = attack.isProjectile ? stats.speed * accelerator : 0; summary.isSpeedChanged = accelerator != 1; summary.spread = attack.spread * steadiness; summary.isSpreadChanged = steadiness != 1; @@ -586,7 +639,7 @@ AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool i summary.pieces = 1; summary.isPiecesChanged = false; } - if (levelDef.limit > 0 && !isCharged) + if (levelDef.limit > 0 && variant != VARIANT_CHARGED) summary.out = levelDef.limit; else if (kind == ATTACK_BOOMERANG || kind == ATTACK_MINE) summary.out = levelDef.count; @@ -606,10 +659,10 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) 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) + AttackVariant variant = variant_get(charge); + const AttackDef& def = variant == VARIANT_CHARGED ? weapon.charged : weapon.normal; + AttackStats stats = variant == VARIANT_CHARGED ? level.charged : level.normal; + if (weapon.mode == FIRE_DRAWN) stats = stats_blend(level, charge); AttackKind kind = def.kind; @@ -630,6 +683,11 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) stats.volley = std::min(stats.volley, level.limit - out); } + // A continuous weapon only ever has one attack going from a hand (which keeps itself going while held), so firing + // it again does nothing. + bool isContinuing = weapon.mode == FIRE_CONTINUOUS && variant != VARIANT_CHARGED && !shot.isEcho; + if (isContinuing && continuous_get(fighter, key, shot.hand)) + return false; if (!shot.isSecondary) { fighter.kickFrom = weapon.kick; @@ -642,7 +700,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) bool isLive = false; for (Attack& attack : attacks) { - if (!is_mine_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead) + if (!is_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead) continue; attack_explode(sim, attack); isLive = true; @@ -651,16 +709,21 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) 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 hourglass = is_aging(def, stats) ? fighter.stat_get(STAT_LIFETIME) : 1; + float lifetime = lifetime_get(def, stats) * hourglass; + float accelerator = def.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; + stats.speed *= accelerator; + // A rope or chain is as long as its shot's flight carries it, so it lengthens as the flight does. + if (def.isTethered) + stats.range *= hourglass * accelerator; + float spread = 1 - fighter.stat_get(STAT_SPREAD_CUT); float jitter = def.spread * DEGREE * spread; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.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) + float push = def.isInheriting ? push_get(fighter, aim) : 0; + // A part-charged normal attack (not one whose charge already strengthens it its own way) adds to the speed bonus, + // so it hits harder and fills in, glows and shakes the same way. + float bonus = bonus_get(fighter, def, aim); + if (variant != VARIANT_CHARGED && !weapon.isChargeScaled) bonus *= 1 + charge * PARTIAL_CHARGE_BONUS; stats.damage = std::max(1.0f, std::round(stats.damage * power * bonus)); @@ -671,20 +734,26 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) .aimAngle = vec2_angle(aim), .angle = vec2_angle(aim), .bonus = bonus, - .duration = stats.duration > 0 ? stats.duration : def.duration, + .duration = def.duration, + .fade = stats.fade > 0 ? stats.fade * hourglass : lifetime, + .lifetime = lifetime, .scale = def.scale * stats.scale, - .bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0, + .bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0, .hitsLeft = def.hits, .owner = fighter.index, .weapon = held, .key = key, .hand = shot.hand, - .isCharged = isCharged, + .variant = variant, .isLeft = fighter.is_left(), .isSecondary = shot.isSecondary, }; attack.position = attack.origin + aim * MUZZLE_DISTANCE; + // Charging burns a grenade's fuse down (fully charged, it goes off in the barrel, see below). + if (kind == ATTACK_GRENADE) + attack.lifetime = std::max(TICK, attack.lifetime * (1 - charge)); + // A teleport is one spell however many copies anything else would make: it marks where it will land. if (kind == ATTACK_TELEPORT) { @@ -692,7 +761,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) attack.origin = fighter.center_get(); attack.tip = attack.position; attack.hitsLeft = 0; - attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1); + attack.scale *= fighter.stat_get(STAT_ATTACK_SIZE); attack.isCopy = shot.isEcho; attack_remember(attack); attacks.push_back(attack); @@ -702,21 +771,22 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) 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; + attack.scale *= fighter.stat_get(STAT_ATTACK_SIZE); - int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); + int splits = fighter.stat_get(STAT_COPIES); if (splits > 1) { attack.scale *= SPLIT_SCALE; - attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * SPLIT_DAMAGE)); + attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * fighter.stat_get(STAT_COPY_DAMAGE))); } + // A rigid attack reaches as far as it has grown or shrunk (Enlarger, Splitter). A Vacuum pulls and blows only as + // hard as the shot's power (Doubler repeats, off hands). + if (def.isRigid) + attack.stats.range *= attack.scale / (def.scale * stats.scale); + if (is_cone(def)) + attack.stats.speed *= power; + // 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++) @@ -737,13 +807,15 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) 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); + // A slash sets off a little ahead of the hand, clear of its fighter. + float muzzle = kind == ATTACK_SLASH ? SLASH_DISTANCE * piece.scale + SLASH_GAP : MUZZLE_DISTANCE; + piece.position = piece.origin + vec2_polar(piece.angle, muzzle); piece.isSecondary |= split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2; - if (kind == ATTACK_WISP) + if (def.isOrbiting) { // 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 + // they gather there until it's let go (see orbits_release); otherwise each sets off a little after the // last. int index = v * splits + split; piece.isGathering = fighter.is_down(CONTROL_ATTACK); @@ -755,7 +827,17 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) // 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(sim, piece); + // A fully charged grenade never leaves the barrel: it goes off right there, with no grenade to clonk anyone + // first. + if (kind == ATTACK_GRENADE && variant == VARIANT_CHARGED) + { + attack_explode(sim, piece); + continue; + } + if (def.isInstant) + instant_launch(sim, piece); + if (kind == ATTACK_WHIP) + whip_pull(sim, piece); attacks.push_back(piece); } } @@ -768,18 +850,12 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) if (weapon.isEjecting) sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); - if (isCharged) + if (variant == VARIANT_CHARGED) sim.cues->shake_add(def.shake * FIRE_SHAKE); - // A fireball goes off with an airblast at the muzzle, as wide as the fireball's own blast. - if (kind == ATTACK_FIREBALL) - blast_spawn(sim, {.position = muzzle, - .area = stats.area * fighter.trinket_get(TRINKET_ENLARGER, 1), - .owner = fighter.index, - .isHarmless = true}); // 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); + float absorb = fighter.stat_get(STAT_MOVEMENT); 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)); @@ -787,8 +863,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) 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; + float recoil = fighter.stat_get(STAT_RECOIL) * math_pow(stats.damage * stats.volley, RECOIL_CURVE) * absorb; if (recoil > 0) fighter.impulse_add(fighter.aim * -recoil); return true; @@ -834,17 +909,16 @@ void Weapons::update(Sim& sim) bool isNew = attack.time == 0; attack_remember(attack); attack_move(sim, 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 isResting = (attack.velocity.x == 0 && attack.velocity.y == 0) || kind == ATTACK_FLAME; bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP; - if (!isLine && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) + bool isAir = is_cone(*attack.def()); + if (!isLine && !isAir && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) sim.cues->attack_afterimage_spawn(attack); - if (!attack.isAttached) + if (!attack.isAttached && !isAir) sim.cues->attack_aura_spawn(attack); // Dummies and blocks are checked before fighters, so racers crowding a block can't soak up every swing at it. @@ -854,7 +928,8 @@ void Weapons::update(Sim& sim) for (int i = 0; i < targets; i++) { int target = (i + MAX_FIGHTERS) % targets; - if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !is_target(sim, target, attack.owner)) + bool isStruck = target < STRUCK_MAX && (attack.struck >> target & 1); + if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || isStruck || !is_target(sim, target, attack.owner)) continue; for (int shape = 0; shape < shapeCount; shape++) { @@ -874,9 +949,10 @@ void Weapons::update(Sim& sim) 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. +// No fighter keeps more than ATTACKS_MAX attacks out: past that, their oldest loose ones (anything not anchored to the +// fighter, not the weapon itself out of the 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(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) @@ -888,9 +964,8 @@ void Weapons::attacks_cap(Sim& sim) { 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; + const AttackDef& def = *attack.def(); + bool isHeld = def.anchor != ANCHOR_WORLD || def.isWielded || def.kind == ATTACK_EXPLOSION; if (attack.owner != i || attack.isDead || isHeld) continue; attack.isDead = true; @@ -905,16 +980,22 @@ void Weapons::attack_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); Fighter& fighter = sim.fighters[attack.owner]; - float progress = attack.time / attack.duration; + float progress = attack.progress_get(); float range = attack.stats.range; + attack.radius = def.shape == SHAPE_CIRCLE ? attack.stats.range : def.radius * attack.scale; + attack.hitsLeft = def.isInstant && attack.time > 0 ? 0 : attack.hitsLeft; + if (def.anchor != ANCHOR_WORLD) + path_move(sim, attack); + else if (def.path != PATH_OWN) + flight_move(sim, attack); // A mine, flail ball or grapple left out goes once its slot no longer holds the weapon that put it there (which // could no longer set it off, reel it in or let it go), a mine fizzling with a puff. - bool isLeftOut = def.kind == ATTACK_MINE || def.kind == ATTACK_FLAIL || def.kind == ATTACK_HOOK; + bool isLeftOut = def.isArming || def.isTethered; if (isLeftOut && !attack.isExplosion && fighter.weaponLoadout[attack.key] != attack.weapon) { attack.isDead = true; - if (def.kind == ATTACK_MINE) + if (def.isArming) sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack); return; } @@ -923,14 +1004,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack) { 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; + // The head sweeps its arc overhead (see path_move). // 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. @@ -939,17 +1013,13 @@ void Weapons::attack_move(Sim& sim, Attack& attack) 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)); + fighter.impulse_add(away * HAMMER_REBOUND * (attack.is_charged() ? HAMMER_REBOUND_CHARGED : 1) * + fighter.stat_get(STAT_MOVEMENT)); attack.isRebounded = true; sim.cues->sound_play(SOUND_BOUNCE); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } - if (progress < 1) - break; - - attack.isDead = true; - if (!attack.isCharged) + if (!attack.isDead || !attack.is_charged()) break; attack.radius = range * SLAM_RATIO; sim.cues->sound_play(SOUND_SLAM); @@ -957,123 +1027,33 @@ void Weapons::attack_move(Sim& sim, Attack& attack) sim.cues->burst_spawn(SLAM_BURST, attack.position, 0, 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) + if (fighter.stat_get(STAT_COPIES) > 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) + // Its path carries the tip, never closer in than the lance's own length, so the whole lance stays out in + // front of the hand and a bigger one reaches no further. + float shaft = LANCE_LENGTH * attack.scale; + float along = std::max(shaft, vec2_length(attack.position - attack.origin)); + attack.tip = attack.origin + vec2_polar(attack.angle, along); + attack.position = attack.tip - vec2_polar(attack.angle, shaft); + if (attack.is_charged() && progress < PATH_OUT) sim.cues->burst_spawn(TRAIL_BURST, attack.tip, attack.angle + PI, 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 - : def.kind == ATTACK_ARROW ? ARROW_RADIUS - : BULLET_RADIUS) * - attack.scale; - for (int step = 0; step < steps && !attack.isDead; step++) - { - Vec2 next = attack.position + attack.velocity * (TICK / steps); - bool isWall = sim.world.is_blocking_at(next); - if (isWall && attack.bounces > 0) - { - ricochet(sim, 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 && sim.world.tile_at(next) != TILE_BLOCK) - { - attack.isAttached = true; - attack.hitsLeft = 0; - attack.velocity = {}; - attack.duration = attack.time + ARROW_LINGER; - impact_play(sim, def.kind); - sim.cues->burst_spawn(IMPACT_BURST, next, 0, attack.owner); - break; - } - - attack.position = next; - bool isBlocked = sim.world.is_blocking_at(attack.position); - if (!isBlocked && vec2_length(attack.position - attack.origin) < range) - continue; - attack.isDead = true; - if (isBlocked) - impact_play(sim, def.kind); - sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, 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 (sim.world.is_blocking_at(next) && attack.bounces > 0) - { - ricochet(sim, attack, next); - break; - } - attack.position = next; sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, attack.owner); - - bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range; - if (sim.world.is_blocking_at(attack.position) || isSpent) - attack_explode(sim, attack); - break; - } - case ATTACK_WISP: - wisp_move(sim, attack); break; case ATTACK_TELEPORT: teleport_move(sim, attack); @@ -1081,16 +1061,12 @@ void Weapons::attack_move(Sim& sim, Attack& attack) 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 = fighter.hand_get(attack.hand); @@ -1101,151 +1077,358 @@ void Weapons::attack_move(Sim& sim, Attack& attack) 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 = fighter.hand_get(attack.hand); - attack.radius = BEAM_RADIUS * attack.scale; - attack.isDead = progress >= 1; - if (attack.isCharged) + // once. A plain lash rolls out from the handle along its path and back, going with the hand, in one sweep + // across its cone: in from the upper edge as it unrolls, straight along the aim as it cracks at full reach + // (short of any wall), and on down to the lower edge as it comes back. It hits anywhere in the cone out to + // its front until it cracks. + if (attack.is_charged()) { + attack.origin = fighter.hand_get(attack.hand); + attack.isDead = progress >= 1; 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 sim.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); + float out = vec2_length(attack.position - attack.origin); + float unfurl = std::min(1.0f, progress / PATH_OUT); + float sweep = progress < PATH_OUT ? unfurl - 1 : (progress - PATH_OUT) / (1 - PATH_OUT); + attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * std::min(1.0f, sweep); + Vec2 reach = ray_march(attack.origin, attack.angle, reach_at_get(attack), 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.tip = attack.position + vec2_polar(attack.angle, std::clamp(out - WHIP_HANDLE, 0.0f, attack.length)); 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(sim, 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 = sim.world.is_blocking_at(point); - if (!isBlocked) - attack.position = point; - } - if (!isBlocked) - break; - impact_play(sim, def.kind); - sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, 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. + // Keeps circling its arc while attack is held, whooshing each lap. Let go, its lifetime ends with the lap. int hand = fighter.hand_slot_get(attack.hand); 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; + float lap = 2 * PI * range / attack.stats.speed; + float laps = std::floor(attack.life_get() / lap); + if (isHeld && laps > std::floor((attack.life_get() - TICK) / lap)) sim.cues->sound_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; + if (!isHeld) + attack.lifetime = std::min(attack.lifetime, (laps + 1) * lap); 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 (sim.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; - sim.cues->burst_spawn(EMBER_BURST, attack.position, attack.angle + PI, attack.owner); - if (sim.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range) - attack_explode(sim, 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 (sim.world.is_blocking_at(next)) + // Armed, it blinks, quicker (and beeping) as the end of its lifetime nears. + attack.blink -= is_armed(attack) ? TICK : 0; + if (attack.blink <= 0 && is_armed(attack)) { - attack.duration = attack.time; - impact_play(sim, def.kind); + attack.blink = mine_blink_get(attack); + if (attack.life_get() > attack.lifetime - MINE_HURRY) + sim.cues->sound_play(SOUND_BEEP); } - 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(sim, attack); + case ATTACK_VACUUM: + vacuum_move(sim, 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 (sim.world.is_blocking_at(attack.position)) - hook_attach(sim, attack); - } - attack.isDead = !attack.isAttached && vec2_length(attack.position - fighter.hand_get()) > range; + case ATTACK_GUST: + gust_move(sim, attack); + break; + default: 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) + // A flying shot's hitbox already spans its path this tick, and pokes, beams and lashes reach their own tips. + bool isFlying = def.anchor == ANCHOR_WORLD && def.path != PATH_OWN; + if (!isFlying && def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP) attack.tip = attack.position; + attack.isHeld = false; +} + +// Places an anchored attack along its path from its anchor (its fighter's hand or middle) for its age, ending it once +// its lifetime is up: as far out along its heading as its speed has carried it (an eased one speeding up as it goes), +// out and back, or as far round its arc. One moved by its own code (a Vacuum's cone) just stays at the muzzle along its +// fighter's aim, keeping whatever turn it set off with (a Splitter copy's place in the fan), its own code ending it. +void Weapons::path_move(Sim& sim, Attack& attack) +{ + const AttackDef& def = *attack.def(); + Fighter& fighter = sim.fighters[attack.owner]; + if (def.path == PATH_OWN) + { + if (attack.time == 0) + attack.angle = attack.aimAngle - vec2_angle(fighter.aim); + attack.aimAngle = vec2_angle(fighter.aim) + attack.angle; + attack.origin = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); + attack.position = attack.origin + vec2_polar(attack.aimAngle, MUZZLE_DISTANCE); + attack.tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack)); + return; + } + float age = attack.life_get(); + float out = PATH_OUT * attack.lifetime; + float travel = def.isEased ? age * age / attack.lifetime : age; + if (def.path == PATH_RETURN && age > out) + travel = std::max(0.0f, out * (attack.lifetime - age) / (attack.lifetime - out)); + float distance = attack.stats.speed * travel; + if (def.path == PATH_ARC) + { + attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * (ARC_START + distance / attack.stats.range); + distance = attack.stats.range; + } + attack.origin = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); + attack.position = attack.origin + vec2_polar(attack.angle, distance); + attack.isDead = age >= attack.lifetime; +} + +// Which way is off the surface a bolt landed on at wall, coming from where it is: a slope's own normal, or else the +// side of the wall it ran into. +static Vec2 surface_normal_get(Sim& sim, Vec2 from, Vec2 wall) +{ + Vec2 normal = sim.world.normal_get(wall); + if (normal.x != 0 || normal.y != 0) + return vec2_normalize(normal); + if (sim.world.is_blocking_at({from.x, wall.y})) + return {0, wall.y > from.y ? -1.0f : 1.0f}; + return {wall.x > from.x ? -1.0f : 1.0f, 0}; +} + +// Flies a free shot one tick under its physics: turning toward its fighter's cursor (homing), speeding up toward its +// top speed, slowing with drag and falling (or rising) with gravity, unless a Vacuum holds it, then moving in small +// steps so it can't skip through a thin wall (its hitbox spanning the whole step so it can't skip past targets either) +// until it touches one (see wall_hit). A bouncing shot moves as a small body instead, a passing one straight through, +// and a crawling one on a surface crawls along it. Its lifetime up, it bursts, or fizzles out. +void Weapons::flight_move(Sim& sim, Attack& attack) +{ + const AttackDef& def = *attack.def(); + Vec2 start = attack.position; + Fighter& fighter = sim.fighters[attack.owner]; + // An orbiting shot glides to its place in its fighter's orbit and circles with it until its moment comes, then + // sets off toward wherever the cursor is right then (plus its spread), its flight starting from there. + if (def.isOrbiting && (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; + attack.lifeStart = attack.time; + attack.tip = attack.position; + return; + } + if (def.isOrbiting && attack.velocity.x == 0 && attack.velocity.y == 0) + { + attack.angle += vec2_angle(fighter.controls.cursor - attack.position); + attack.velocity = vec2_polar(attack.angle, attack.stats.speed); + sim.cues->sound_play(SOUND_WISP); + } + attack.isReturning |= def.path == PATH_RETURN && attack.time >= attack.restTime + def.hold; + if (attack.isReturning && def.isTethered) + attack.isDead = is_caught(fighter, attack); + else if (attack.isReturning) + attack.isDead = shot_return(sim, fighter, attack); + else if (attack.isAttached && def.wall == WALL_CRAWL) + crawl_move(sim, attack); + else if (!attack.isAttached) + { + float speed = vec2_length(attack.velocity); + Vec2 toward = fighter.controls.cursor - attack.position; + if (def.homing > 0 && speed > 0 && vec2_length(toward) > HOMING_MIN) + { + float heading = vec2_angle(attack.velocity); + float turn = std::remainder(vec2_angle(toward) - heading, 2 * PI); + attack.velocity = vec2_polar(heading + std::clamp(turn, -def.homing * TICK, def.homing * TICK), speed); + } + if (def.accelerate > 0 && speed > 0) + attack.velocity = vec2_normalize(attack.velocity) * + std::min(attack.stats.speed * def.speedup, speed + def.accelerate * TICK); + attack.velocity = attack.velocity * def.drag; + attack.velocity.y += attack.isHeld ? 0 : def.gravity * TICK; + // An arming shot (or one going out and back) slowed to a crawl comes to rest, arming (or waiting to head back) + // from then. Once rested, it only drifts. + bool isResting = def.isArming || def.path == PATH_RETURN; + if (isResting && std::isinf(attack.restTime) && vec2_length(attack.velocity) < REST_SPEED) + { + attack.velocity = {}; + attack.restTime = attack.time; + } + if (def.isAligned && (attack.velocity.x != 0 || attack.velocity.y != 0)) + attack.angle = vec2_angle(attack.velocity); + // A tethered shot pulls up short at the end of its chain. + Vec2 hand = fighter.hand_get(attack.hand); + Vec2 offset = attack.position + attack.velocity * TICK - hand; + if (def.isTethered && vec2_length(offset) > attack.stats.range) + attack.velocity = (hand + vec2_normalize(offset) * attack.stats.range - attack.position) * (1 / TICK); + + Vec2 next = attack.position + attack.velocity * TICK; + Vec2 wall; + WallCheck isWall = def.isPassingPlatforms ? &World::is_solid_at : &World::is_blocking_at; + if (def.wall == WALL_BOUNCE) + body_bounce(sim, attack); + else if (def.wall == WALL_PASS && attack.bounces <= 0) + attack.position = next; + else if (is_wall_reached(sim, attack, next, wall, isWall)) + wall_hit(sim, attack, wall, attack.isHeld); + } + attack.tip = start; + + bool isOver = !attack.isDead && attack.life_get() >= attack.lifetime; + if (isOver && def.isBursting) + attack_explode(sim, attack); + else if (isOver) + { + attack.isDead = true; + if (def.wall == WALL_END) + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); + } +} + +// A flying shot touching a wall: held in a Vacuum, it glances off. Otherwise Ricochet bounces it while it has bounces +// left, and then its wall response decides: it ends (with a thud), bursts, sticks and lingers (harmless, as a foothold +// for its fighter, though a breakable block just takes the hit), settles against the surface to crawl along it the way +// it was going, or stops dead. +void Weapons::wall_hit(Sim& sim, Attack& attack, Vec2 wall, bool isHeld) +{ + const AttackDef& def = *attack.def(); + if (isHeld) + { + attack.velocity = reflect_get(sim, attack.position, wall, attack.velocity); + return; + } + if (attack.bounces > 0) + { + ricochet(sim, attack, wall); + return; + } + bool isBlock = sim.world.tile_at(wall) == TILE_BLOCK; + switch (def.wall) + { + case WALL_STICK: + if (!isBlock) + { + attack.isAttached = true; + attack.hitsLeft = 0; + attack.velocity = {}; + attack.lifetime = attack.life_get() + ARROW_LINGER; + impact_play(sim, def.kind); + sim.cues->burst_spawn(IMPACT_BURST, wall, 0, attack.owner); + break; + } + [[fallthrough]]; + case WALL_END: + attack.isDead = true; + impact_play(sim, def.kind); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); + break; + case WALL_BURST: + attack.position = wall; + attack_explode(sim, attack); + break; + case WALL_CRAWL: + { + Vec2 normal = surface_normal_get(sim, attack.position, wall); + Vec2 along = {-normal.y, normal.x}; + along = vec2_dot(attack.velocity, along) < 0 ? along * -1 : along; + for (float reach = 0; reach < CRAWL_SNAP && !sim.world.is_blocking_at(attack.position - normal); reach++) + attack.position = attack.position - normal; + attack.side = along.x * -normal.y - along.y * -normal.x > 0 ? 1 : -1; + attack.velocity = along * attack.stats.speed; + attack.isAttached = true; + break; + } + case WALL_LATCH: + attack.position = wall; + hook_attach(sim, attack); + break; + case WALL_RETURN: + attack.isReturning = true; + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); + [[fallthrough]]; + case WALL_STOP: + case WALL_BOUNCE: + case WALL_PASS: + attack.velocity = {}; + impact_play(sim, def.kind); + break; + } +} + +// A crawling shot on a surface: each step it turns as far toward its surface as it can still move, from straight into +// it round to straight away from it, so it hugs floors, walls, ceilings, platforms, corners and edges alike, at its +// speed. Boxed in, it turns back. One left inside a wall (fired from in one) fizzles out. +void Weapons::crawl_move(Sim& sim, Attack& attack) +{ + float speed = attack.stats.speed; + int steps = std::max(1, (int)(speed * TICK / CRAWL_STEP)); + for (int step = 0; step < steps; step++) + { + float heading = vec2_angle(attack.velocity); + bool isMoved = false; + for (int turn = 0; turn <= CRAWL_TURNS && !isMoved; turn++) + { + float angle = heading + attack.side * (PI / 2 - turn * PI / CRAWL_TURNS); + Vec2 ahead = attack.position + vec2_polar(angle, CRAWL_STEP); + if (sim.world.is_blocking_at(ahead)) + continue; + attack.position = ahead; + attack.velocity = vec2_polar(angle, speed); + isMoved = true; + } + if (!isMoved) + attack.velocity = attack.velocity * -1; + } + attack.angle = vec2_angle(attack.velocity); + attack.isDead |= sim.world.is_blocking_at(attack.position); +} + +// A bouncing shot moves as a small box body, staying on the ground across ramps the way a fighter does (stepping over +// a ramp's crest and down its far side). Off surfaces it bounces back (with a knock), losing some speed, slanting off +// ramps. On the ground it rolls downhill under its gravity, and on the flat to a stop, turning as it goes. A bounce +// Ricochet counts starts its flight afresh, as a ricochet does any shot's. +void Weapons::body_bounce(Sim& sim, Attack& attack) +{ + const AttackDef& def = *attack.def(); + float size = def.body * attack.scale; + Body body = { + .box = {attack.position.x - size / 2, attack.position.y - size / 2, size, size}, + .velocity = attack.velocity, + .slope = attack.slope, + .isGrounded = attack.isGrounded, + }; + if (body.isGrounded) + body.velocity.x += def.gravity * body.slope / (1 + body.slope * body.slope) * TICK; + Vec2 before = body.velocity; + sim.world.body_move(body); + bool isBounced = false; + bool isRamp = body.normal.x != 0 || body.normal.y != 0; + float into = vec2_dot(before, body.normal); + if (isRamp && into < -SHOT_BOUNCE_MIN) + { + body.velocity = before - body.normal * (into * (1 + def.bounce)); + body.isGrounded = false; + isBounced = true; + } + if (!isRamp && body.velocity.x == 0 && std::abs(before.x) > SHOT_BOUNCE_MIN) + { + body.velocity.x = -before.x * def.bounce; + isBounced = true; + } + if (!isRamp && body.velocity.y == 0 && std::abs(before.y) > SHOT_BOUNCE_MIN) + { + body.velocity.y = -before.y * def.bounce; + body.isGrounded = false; + isBounced = true; + } + if (isBounced && attack.bounces > 0) + { + ricochet_count(sim, attack); + attack.lifeStart = attack.time; + } + else if (isBounced) + impact_play(sim, def.kind); + if (body.isGrounded && body.slope == 0) + body.velocity.x = + std::clamp(0.0f, body.velocity.x - def.friction * TICK, body.velocity.x + def.friction * TICK); + attack.position = {body.box.x + size / 2, body.box.y + size / 2}; + attack.velocity = body.velocity; + attack.isGrounded = body.isGrounded; + attack.slope = body.slope; + attack.angle += body.velocity.x * ROLL_SPIN * TICK / std::max(1.0f, size * PI); } // Keeps this tick's pose so drawing can blend from it. New attacks remember their first pose @@ -1282,26 +1465,53 @@ void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point) } impact_play(sim, 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; + // Explosions deal full damage at their centre, down to a fraction at the edge, by the target's nearest point. A + // Vacuum's suck bites harder the closer to its muzzle. + float taper = is_cone(def) ? cone_strength_get(attack, point) : 1; + taper *= def.kind == ATTACK_GRENADE ? GRENADE_CONTACT_SHARE : 1; + // The double donk: a marking attack (a grenade's clonk) marks whoever it hits, and a blast from the same owner's + // same weapon landing on them soon after hits three times as hard, with a sound of its own. + bool isFighter = target < MAX_FIGHTERS; + float& donkWindow = + isFighter ? sim.fighters[target].donkWindow : sim.world.dummies[target - MAX_FIGHTERS].donkWindow; + int& donkOwner = isFighter ? sim.fighters[target].donkOwner : sim.world.dummies[target - MAX_FIGHTERS].donkOwner; + int& donkWeapon = isFighter ? sim.fighters[target].donkWeapon : sim.world.dummies[target - MAX_FIGHTERS].donkWeapon; + bool isMarked = donkWindow > 0 && donkOwner == attack.owner && donkWeapon == attack.weapon; + if (def.kind == ATTACK_EXPLOSION && isMarked) + { + taper *= DONK_DAMAGE; + donkWindow = 0; + attack.isDonked = true; + attack.duration = std::max(attack.duration, DONK_FLASH); + sim.cues->sound_play(SOUND_DONK); + } + if (def.isMarking) + { + donkWindow = DONK_WINDOW; + donkOwner = attack.owner; + donkWeapon = attack.weapon; + } if (def.kind == ATTACK_EXPLOSION) { Rect box = target_box(sim, 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); + 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); + SoundId sound = hit_sound_get(attack.weapon >= 0 ? WEAPON_DEFS[attack.weapon].hit : HIT_BLUNT, damage); 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) + // A grenade clonks everyone it passes once, one right after another. + if (def.kind == ATTACK_GRENADE) + { + attack.struck |= target < STRUCK_MAX ? 1u << target : 0; + attack.hitCooldown = 0; + } + attack.isDead |= def.isShot && attack.hitsLeft <= 0; + if (def.wall == WALL_LATCH) hook_attach(sim, attack); // Uppercuts knock the way they rise. Rect box = target_box(sim, target); @@ -1313,7 +1523,7 @@ void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point) .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), + .knock = attack.isGentle ? 0 : def.knock, .amount = damage, .owner = attack.owner, .target = target, @@ -1322,18 +1532,17 @@ void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point) } // 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). +// freezes in hitstop, its Boxing Glove adds to the knock, and its Embiggener to the damage. // 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(Sim& sim, Damage damage) { Fighter& attacker = sim.fighters[damage.owner]; Rect box = target_box(sim, 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))); + damage.amount = std::max(1, (int)std::round(damage.amount * attacker.stat_get(STAT_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); + attacker.hitstop_add(damage.hitstop); + damage.knock *= attacker.stat_get(STAT_KNOCKBACK); if (damage.target < MAX_FIGHTERS) sim.fighters[damage.target].damage_take(sim, damage); else @@ -1360,7 +1569,7 @@ void Weapons::damage_deal(Sim& sim, Damage damage) // bonus for the fighter's speed, at most once every CONTACT_INTERVAL. void Weapons::contact_update(Sim& sim, Fighter& fighter) { - float base = fighter.trinket_get(TRINKET_PEBBLE, 0); + float base = fighter.stat_get(STAT_CONTACT_DAMAGE); fighter.contactCooldown -= TICK; if (base <= 0 || !fighter.isAlive || fighter.hitstop > 0 || fighter.contactCooldown > 0) return; @@ -1378,7 +1587,7 @@ void Weapons::contact_update(Sim& sim, Fighter& fighter) 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; + float bonus = 1 + (SPEED_DAMAGE + fighter.stat_get(STAT_SPEED_DAMAGE)) * 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}; @@ -1391,7 +1600,7 @@ void Weapons::contact_update(Sim& sim, Fighter& fighter) .amount = damage, .owner = fighter.index, .target = target, - .sound = hit_sound_get(HIT_BLUNT, damage >= HEAVY_DAMAGE), + .sound = hit_sound_get(HIT_BLUNT, damage), }); fighter.contactCooldown = CONTACT_INTERVAL; return; @@ -1410,7 +1619,7 @@ void Weapons::orbits_update(Sim& sim) float scale = sim.fighters[i].scale; hovering.clear(); for (Attack& attack : attacks) - if (attack.owner == i && attack.def()->kind == ATTACK_WISP && !attack.isDead && + if (attack.owner == i && attack.def()->isOrbiting && !attack.isDead && (attack.isGathering || attack.time < attack.duration)) hovering.push_back(&attack); int placed = 0; @@ -1432,7 +1641,7 @@ void Weapons::orbits_update(Sim& sim) // 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) +void Weapons::orbits_release(Fighter& fighter) { int count = 0; for (Attack& attack : attacks) @@ -1448,54 +1657,6 @@ void Weapons::wisps_release(Fighter& fighter) } } -// 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(Sim& sim, Attack& attack) -{ - Fighter& fighter = sim.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); - sim.cues->sound_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 (sim.world.is_solid_at(next) && attack.bounces > 0) - { - ricochet(sim, attack, next); - return; - } - attack.length += vec2_length(next - attack.position); - attack.position = next; - bool isBlocked = sim.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(sim, ATTACK_PROJECTILE); - sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, 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. @@ -1534,17 +1695,19 @@ void Weapons::teleport_move(Sim& sim, Attack& attack) // 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. +// links growing out of hand for rapid-fire weapons. Each coil makes the links thicker. std::vector Weapons::chain_get(Sim& sim, Fighter& fighter) { std::vector links; if (!fighter.is_trinket_equipped(TRINKET_TESLA)) return links; - float range = TESLA_RANGES[fighter.trinket_level_get(TRINKET_TESLA)]; + float range = fighter.stat_get(STAT_ARC_RANGE); + float radius = TESLA_RADIUS * fighter.trinket_count(TRINKET_TESLA); std::vector pieces; for (Attack& attack : attacks) - if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION) + if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION && + !is_cone(*attack.def())) pieces.push_back(attack.position); int count = pieces.size(); @@ -1567,7 +1730,7 @@ std::vector Weapons::chain_get(Sim& sim, Fighter& fighter) 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}); + links.push_back({pieces[i], pieces[j], radius}); } } return links; @@ -1579,7 +1742,7 @@ void Weapons::tesla_update(Sim& sim) for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; - float damage = fighter.trinket_get(TRINKET_TESLA, 0); + float damage = fighter.stat_get(STAT_SHOCK_DAMAGE); for (Capsule& link : chain_get(sim, fighter)) { for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) @@ -1594,11 +1757,11 @@ void Weapons::tesla_update(Sim& sim) shock = TESLA_INTERVAL; damage_deal(sim, {.point = point, .source = fighter.center_get(), - .knock = KNOCK_SLIGHT, + .knock = 0, .amount = (int)damage, .owner = i, .target = target, - .sound = SOUND_ZAP}); + .sound = hit_sound_get(HIT_ELECTRIC, (int)damage)}); } } } @@ -1617,17 +1780,17 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) return paths; 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; + AttackVariant variant = variant_get(fighter.charge); + const AttackDef& def = variant == VARIANT_CHARGED ? weapon.charged : weapon.normal; AttackKind kind = def.kind; - AttackStats stats = isCharged ? level.charged : level.normal; - if (weapon.isDrawn) + AttackStats stats = variant == VARIANT_CHARGED ? level.charged : level.normal; + if (weapon.mode == FIRE_DRAWN) stats = stats_blend(level, fighter.charge); if (kind == ATTACK_TELEPORT) { Vec2 spot = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); Vec2 from = fighter.center_get(); - Path path = {.area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1)}; + Path path = {.area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE)}; float length = vec2_length(spot - from); for (float travelled = 0; travelled < length; travelled += TRAJECTORY_STEP) path.steps.push_back(from + (spot - from) * (travelled / length)); @@ -1635,26 +1798,39 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) paths.push_back(path); return paths; } + // A Vacuum's cone (one per Splitter copy, fanned), from the muzzle out to its reach. + int splits = fighter.stat_get(STAT_COPIES); + if (is_cone(def)) + { + Vec2 muzzle = fighter.hand_get() + fighter.aim * MUZZLE_DISTANCE; + float reach = stats.range * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); + for (int split = 0; split < splits; split++) + { + float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 0; + Vec2 tip = muzzle + vec2_polar(vec2_angle(fighter.aim) + offset, reach); + paths.push_back({.steps = {muzzle, tip}, .cone = VACUUM_CONE}); + } + return paths; + } 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); + kind == ATTACK_BOOMERANG || (kind == ATTACK_HOOK && variant != VARIANT_CHARGED); if (!isArrow && !isStraight) return paths; // 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 area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); float reach = std::min(stats.range, TRAJECTORY_REACH); for (int split = 0; split < splits; split++) { - float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0)) : 0; + float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 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); + float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + 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; + int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; for (float travelled = area > 0 ? MUZZLE_DISTANCE : 0; travelled < reach; travelled += TRAJECTORY_STEP) @@ -1669,7 +1845,7 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) velocity = reflect_get(sim, point, next, velocity); continue; } - velocity.y += isArrow ? ARROW_GRAVITY * time : 0; + velocity.y += def.gravity * time; path.steps.push_back(point); point = next; } @@ -1680,12 +1856,14 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) return paths; } -// Latches the hook where it is and sets the rope to its fighter's current distance. Charged hooks start pulling. +// Latches the hook where it is, holding until let go, and sets the rope to its fighter's current distance. Charged +// hooks start pulling. void Weapons::hook_attach(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; attack.isAttached = true; - attack.isPulling = attack.isCharged; + attack.lifetime = LIFETIME_INFINITE; + attack.isPulling = attack.is_charged(); attack.hitsLeft = 0; attack.length = std::min(vec2_length(fighter.center_get() - attack.position), attack.stats.range); sim.cues->sound_play(SOUND_LATCH); @@ -1693,69 +1871,62 @@ void Weapons::hook_attach(Sim& sim, Attack& attack) sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, 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(Sim& sim, Attack& piece) +// An instant attack reaches its full range at once along its aim, stopping at the first wall (or, for a latching one, +// the first target), where a latching one latches. Each Ricochet bounce marches its full range again as a segment of +// its own, its falloff starting afresh. A bursting one blows up at its tip, without a push. +void Weapons::instant_launch(Sim& sim, Attack& piece) { - if (piece.def()->kind == ATTACK_WHIP) + const AttackDef& def = *piece.def(); + bool isLatching = def.wall == WALL_LATCH; + auto isBlocked = [&](Vec2 point) { + for (int target = 0; isLatching && target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) + if (is_target(sim, target, piece.owner) && is_point_in_rect(point, target_box(sim, target))) + return true; + return sim.world.is_blocking_at(point); + }; + Vec2 start = def.kind == ATTACK_WHIP ? piece.origin : piece.position; + piece.velocity = {}; + piece.tip = ray_march(start, piece.angle, piece.stats.range, isBlocked); + if (isLatching) { - auto isBlocked = [&](Vec2 point) { - return sim.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) - sim.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL * - sim.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1)); - if (piece.isCharged) - blast_spawn(sim, { - .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 sim.world.is_blocking_at(point); - }; - piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); - sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, 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(sim, 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); - sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); - } - } - - if (piece.def()->kind == ATTACK_HOOK && piece.isCharged) - { - auto isBlocked = [&](Vec2 point) { - for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) - if (is_target(sim, target, piece.owner) && is_point_in_rect(point, target_box(sim, target))) - return true; - return sim.world.is_blocking_at(point); - }; - piece.position = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); + piece.position = piece.tip; if (sim.world.is_blocking_at(piece.position)) hook_attach(sim, piece); + return; } + sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner); + Attack segment = piece; + for (int i = 0; i < piece.bounces; i++) + { + Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); + segment.angle = vec2_angle(reflect_get(sim, 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); + sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); + } + if (def.isBursting) + blast_spawn(sim, { + .position = piece.tip, + .area = piece.stats.area * piece.scale / piece.stats.scale, + .damage = piece.stats.damage, + .owner = piece.owner, + .sound = SOUND_NONE, // the attack's own sound already carries it + .weapon = piece.weapon, + .isSecondary = piece.isSecondary, + }); +} + +// Pulls a whip's wielder toward a wall its lash (reaching as far as it reaches when fired) meets short of full length. +void Weapons::whip_pull(Sim& sim, Attack& piece) +{ + Fighter& fighter = sim.fighters[piece.owner]; + float reach = reach_at_get(piece); + Vec2 end = ray_march(piece.origin, piece.angle, reach, [&](Vec2 point) { return sim.world.is_blocking_at(point); }); + Vec2 lash = end - piece.origin; + if (!piece.isCopy && vec2_length(lash) < reach - RAY_STEP) + fighter.impulse_add(vec2_normalize(lash) * WHIP_PULL * fighter.stat_get(STAT_MOVEMENT)); } // Bursts a rocket, mine or fireball into a pushing explosion, keeping the damage raised by any ricochets and cut by @@ -1779,32 +1950,25 @@ void Weapons::attack_explode(Sim& sim, Attack& attack) } // 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. A harmless blast (an airblast) only knocks everyone but its -// owner within its area, launching them if close enough, shown as a ring. +// fighter away from the centre, harder the closer they are. void Weapons::blast_spawn(Sim& sim, Blast blast) { - float reach = blast.area * (blast.isHarmless ? 1 : KNOCKBACK_REACH); + float reach = blast.area * KNOCKBACK_REACH; + float boxing = blast.owner >= 0 ? sim.fighters[blast.owner].stat_get(STAT_KNOCKBACK) : 1; for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.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(); - bool isPushed = blast.isHarmless ? i != blast.owner : blast.isPushing; - if (!isPushed || !fighter.isAlive || distance >= reach) + // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback (which the + // blast's owner's Boxing Glove adds to). + float absorb = + i == blast.owner ? fighter.stat_get(STAT_MOVEMENT) : fighter.stat_get(STAT_KNOCKBACK_TAKEN) * boxing; + if (!blast.isPushing || !fighter.isAlive || distance >= reach) continue; Vec2 push = vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb; - if (blast.isHarmless) - fighter.knock_take(push, blast.owner); - else - fighter.impulse_add(push); - } - if (blast.isHarmless) - { - sim.cues->ring_spawn(blast.position, reach, blast.owner); - return; + fighter.impulse_add(push); } Attack attack = { @@ -1851,13 +2015,13 @@ void Weapons::solids_add(Fighter& fighter, World& world) if (attack.owner != fighter.index || attack.isDead) continue; AttackKind kind = attack.def()->kind; - if (kind == ATTACK_BOOMERANG && attack.isCharged) + if (kind == ATTACK_BOOMERANG && attack.is_charged()) { - float size = BOOMERANG_RADIUS * attack.scale; + float size = attack.radius; world.obstacles.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2}); } // A stuck arrow's ledge is the box around its hit shape. - if (kind == ATTACK_ARROW && attack.isAttached && attack.time < attack.duration - ARROW_FADE) + if (kind == ATTACK_ARROW && attack.isAttached && attack.life_get() < attack.lifetime - ARROW_FADE) { Vec2 low = {std::min(attack.position.x, attack.tip.x), std::min(attack.position.y, attack.tip.y)}; Vec2 high = {std::max(attack.position.x, attack.tip.x), std::max(attack.position.y, attack.tip.y)}; @@ -1878,6 +2042,16 @@ bool Weapons::is_spinning(Fighter& fighter) } // The latched grapple hook the fighter is hanging from, if any. +// The attack a continuous weapon (see FireMode) in a weapon slot keeps going from a hand, or none. +Attack* Weapons::continuous_get(Fighter& fighter, int key, int hand) +{ + for (Attack& attack : attacks) + if (attack.owner == fighter.index && attack.key == key && attack.hand == hand && !attack.isCopy && + !attack.isDead && attack.def()->kind == ATTACK_VACUUM) + return &attack; + return nullptr; +} + Attack* Weapons::hook_get(Fighter& fighter) { for (Attack& attack : attacks) @@ -1917,55 +2091,176 @@ bool Weapons::is_recalled(Sim& sim, Fighter& fighter) 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(Sim& sim, Attack& attack) +float mine_blink_get(Attack& attack) { - Fighter& fighter = sim.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; - sim.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(sim, 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; + float left = std::clamp((attack.lifetime - attack.life_get()) / MINE_HURRY, 0.0f, 1.0f); + return MINE_BLINK_MIN + (MINE_BLINK - MINE_BLINK_MIN) * left; } -// 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. +// Whether a Vacuum moves a shot right now: one whose attack drifts, as long as it's loose going its own way (a wisp +// once sent, a flail ball not being reeled in, a grapple out) or stuck to a surface (which it comes loose from). +static bool is_drifting(Attack& shot) +{ + const AttackDef& def = *shot.def(); + bool isOrbiting = def.isOrbiting && shot.velocity.x == 0 && shot.velocity.y == 0; + bool isWhiffing = def.isInstant && !shot.isAttached; + return def.isDrifting && !shot.isReturning && !isOrbiting && !isWhiffing; +} + +// Gives a shot back a share of the flight it set off with, never more than it's used, which winds back its damage +// falloff with it (a fuse too). One that flies on forever gets back a share of its fade. A hook, whose falloff goes by +// distance, gets back a share of its range. +static void shot_restore(Attack& shot, float share) +{ + AttackKind kind = shot.def()->kind; + float lifetime = std::isinf(shot.lifetime) ? shot.fade : shot.lifetime; + shot.lifeStart = std::max(shot.lifeStart, std::min(shot.time, shot.lifeStart + lifetime * share)); + if (kind != ATTACK_HOOK) + return; + + Vec2 back = shot.position - shot.origin; + shot.origin = shot.origin + vec2_normalize(back) * std::min(shot.stats.range * share, vec2_length(back)); +} + +// Moves every shot in an attack's cone (from its position along its aim, out to its range), anyone's: a blow turns each +// to fly straight out of the cone from its point (along the aim, give or take where in the cone it is), at its own +// speed plus the blow's, and pull speeds each toward the point. A shot stuck to a surface comes loose first (a +// grapple's hook too), popping out toward the point at the cone's own speed when pulled, and glancing off the surface +// if it would head straight back in. A claiming attack makes other fighters' shots its owner's, burst into its colour +// (all but those tied to their fighter's weapon: on a rope or chain, waiting to be set off, or the weapon itself). The +// owner's own shots then get back restore's share of what they set off with (see shot_restore). +void Weapons::shots_drift(Sim& sim, Attack& source, float blow, float pull, float restore) +{ + for (Attack& shot : attacks) + { + AttackKind kind = shot.def()->kind; + if (&shot == &source || shot.isExplosion || !is_drifting(shot) || !is_in_cone(source, shot.position)) + continue; + const AttackDef& def = *shot.def(); + bool isTied = def.isTethered || def.isArming || def.isWielded; + if (source.def()->isClaiming && shot.owner != source.owner && !isTied) + { + shot.ownerPrevious = shot.owner; + shot.owner = source.owner; + shot.claimTime = shot.time; + shot.key = -1; + shot.isSecondary = false; + shot.struck = 0; + shot.hitCooldown = 0; + sim.cues->burst_spawn(SPARK_BURST, shot.position, source.aimAngle, source.owner); + } + Vec2 toward = vec2_normalize(source.position - shot.position); + bool isLoosened = shot.isAttached; + // Boxing Glove moves shots harder, as it knocks fighters harder. + float boxing = sim.fighters[source.owner].stat_get(STAT_KNOCKBACK); + if (isLoosened) + { + shot.isAttached = false; + shot.hitsLeft = kind == ATTACK_ARROW ? shot.def()->hits : shot.hitsLeft; + shot.velocity = pull > 0 ? toward * source.stats.speed * boxing : Vec2{}; + } + shot.isHeld |= pull > 0; + shot.velocity = shot.velocity + toward * pull * boxing; + if (blow > 0) + shot.velocity = toward * -(vec2_length(shot.velocity) + blow * boxing); + Vec2 next = shot.position + shot.velocity * TICK; + if (isLoosened && sim.world.is_blocking_at(next)) + shot.velocity = reflect_get(sim, shot.position, next, shot.velocity); + if (shot.def()->isAligned) + shot.angle = vec2_angle(shot.velocity); + if (restore > 0 && shot.owner == source.owner) + shot_restore(shot, restore); + } +} + +// The Vacuum's suck: a long, narrow cone from the muzzle along the aim, following the hand, kept going for as long as +// it's held (the attack control down with it still in that hand). It spins up to its full reach, and let go, it winds +// back down and goes (taken up again on the way down, it spins back up). Fighters in it (and anyone's shots) are +// pulled toward the muzzle, topped up to its pull speed rather than stacking. Once every interval (its cooldown) +// it nibbles at every target whose middle is in the cone. (The air streaming in along it is drawn from the cone, see +// Scene::suck_draw.) +void Weapons::vacuum_move(Sim& sim, Attack& attack) +{ + Fighter& fighter = sim.fighters[attack.owner]; + int hand = fighter.hand_slot_get(attack.hand); + bool isHeld = + fighter.isAlive && fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; + attack.isReturning = !isHeld; + attack.length = std::clamp(attack.length + (isHeld ? TICK / VACUUM_SPIN_UP : -TICK / VACUUM_SPIN_DOWN), 0.0f, 1.0f); + attack.isDead = !isHeld && attack.length <= 0; + for (int i = 0; i < sim.fighterCount; i++) + { + Fighter& target = sim.fighters[i]; + Vec2 center = target.center_get(); + if (!is_target(sim, i, attack.owner) || !is_in_cone(attack, center)) + continue; + // Boxing Glove pulls harder, as it knocks harder. + float boxing = fighter.stat_get(STAT_KNOCKBACK); + Vec2 toward = vec2_normalize(attack.position - center); + float room = std::max(0.0f, attack.stats.speed * boxing - vec2_dot(target.body.velocity, toward)); + float pull = std::min(room, VACUUM_PULL_RATE * boxing * TICK) * target.stat_get(STAT_KNOCKBACK_TAKEN); + target.body.velocity = target.body.velocity + toward * pull; + } + // Each interval (the hit cooldown counting it down), everyone in the cone can be nibbled again. + float interval = fighter.level_get(attack.key).cooldown / fighter.stat_get(STAT_ATTACK_RATE); + bool isPulse = attack.hitCooldown <= 0; + if (isPulse) + { + attack.struck = 0; + attack.hitCooldown = interval; + } + int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); + for (int target = 0; target < targets; target++) + { + Rect box = target_box(sim, target); + Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; + if ((attack.struck >> target & 1) || !is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) + continue; + attack.struck |= 1u << target; + attack_hit(sim, attack, target, center); + } + // Only the first of a fan of copies (or repeats) lengthens lives, so they don't add up. + bool isLengthening = isPulse && !attack.isCopy; + shots_drift(sim, attack, 0, VACUUM_PULL_RATE * TICK, isLengthening ? VACUUM_RESTORE : 0); +} + +// The Vacuum's airblast: once, as it goes off, its cone hits everyone in it for a little damage and knocks every +// fighter in it away (launching them), both harder the closer they are, and turns every shot in it to fly out along +// the aim, claiming other fighters' (all as good as fresh, see shots_drift). (The air it blows out is drawn from the +// cone, see Scene::gust_draw.) +void Weapons::gust_move(Sim& sim, Attack& attack) +{ + attack.isDead = attack.life_get() >= attack.lifetime; + if (attack.time > 0) + return; + int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); + for (int target = 0; target < targets; target++) + { + Rect box = target_box(sim, target); + Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; + if (!is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) + continue; + attack_hit(sim, attack, target, center); + if (target >= MAX_FIGHTERS) + continue; + // Boxing Glove adds to it as to any knock. + Fighter& fighter = sim.fighters[target]; + float strength = attack.stats.speed * cone_strength_get(attack, center) * + fighter.stat_get(STAT_KNOCKBACK_TAKEN) * sim.fighters[attack.owner].stat_get(STAT_KNOCKBACK); + fighter.knock_take(vec2_normalize(center - attack.position) * strength, attack.owner); + } + shots_drift(sim, attack, attack.stats.speed * GUST_SHOT_SHARE, 0, attack.isCopy ? 0 : GUST_RESTORE); +} + +// A weapon (by its key, see Fighter::weapon_key) is busy once as many of it are out of the hand (wielded attacks) 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; + out += attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy && + attack.def()->isWielded; } return out >= fighter.level_get(key).count; } diff --git a/src/weapon.h b/src/weapon.h index b93ba0d..2340856 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -6,9 +6,10 @@ #include constexpr float BLAST_RING_ALPHA = 0.2f; -constexpr float RICOCHET_BONUS = 0.5f; +constexpr float RICOCHET_BONUS = 0.25f; +// Hits for an attack that keeps hitting (a hit interval apart) as long as it's out. +constexpr int HITS_ENDLESS = 1 << 30; constexpr int WHIP_RAYS = 9; -constexpr float WHIP_UNFURL = 0.6f; class Fighter; struct Dummy; @@ -16,15 +17,33 @@ struct Sim; class World; constexpr int LEVEL_COUNT = 3; -constexpr int WEAPON_COUNT = 16; +constexpr int WEAPON_COUNT = 19; constexpr int SLOT_MAX = 9; constexpr int SLOTS_DEFAULT = 2; constexpr float RANGE_INFINITE = std::numeric_limits::infinity(); +constexpr float LIFETIME_INFINITE = std::numeric_limits::infinity(); +// A hit dealing this much or more sounds heavy. +constexpr int HEAVY_DAMAGE = 40; +// A cooldown shorter than this is too quick to show (as a bar, or a fading weapon). +constexpr float COOLDOWN_VISIBLE = 0.25f; + +// An attack's two forms: its plain one, and the one a full charge lets loose. +enum AttackVariant +{ + VARIANT_NORMAL, + VARIANT_CHARGED +}; + +inline AttackVariant variant_get(float charge) +{ + return charge >= 1 ? VARIANT_CHARGED : VARIANT_NORMAL; +} enum AttackKind { ATTACK_ARROW, ATTACK_BEAM, + ATTACK_BOLT, ATTACK_BOOMERANG, ATTACK_BULLET, ATTACK_DETONATE, @@ -32,6 +51,8 @@ enum AttackKind ATTACK_FIREBALL, ATTACK_FLAIL, ATTACK_FLAME, + ATTACK_GRENADE, + ATTACK_GUST, ATTACK_HOOK, ATTACK_MINE, ATTACK_POKE, @@ -43,29 +64,143 @@ enum AttackKind ATTACK_SWING, ATTACK_TELEPORT, ATTACK_UPPERCUT, + ATTACK_VACUUM, 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. +// What an attack moves with: nothing (flying free in the world), its fighter's hand, or its fighter's middle. +enum AttackAnchor +{ + ANCHOR_WORLD, + ANCHOR_HAND, + ANCHOR_BODY +}; + +// How an anchored attack moves out from its anchor over its lifetime: straight along its heading at its speed, out and +// back (going out at its speed for PATH_OUT of its lifetime, then easing back in over the rest), or round an arc at its +// range (sweeping along it at its speed from behind, mirrored when facing left). A free one (anchored to the world) +// flies under its physics instead (see Weapons::flight_move), and one going out and back comes to rest, waits its hold, +// then flies home to its fighter's hand at half the speed it left at. PATH_OWN is moved by its kind's own code. +enum AttackPath +{ + PATH_STRAIGHT, + PATH_RETURN, + PATH_ARC, + PATH_OWN +}; + +// What an attack hits with: a capsule (from its position to its tip, as thick as its radius), a circle as wide as its +// range (an explosion), or a cone along its aim out to its range (see is_in_cone). +enum AttackShape +{ + SHAPE_CAPSULE, + SHAPE_CIRCLE, + SHAPE_CONE +}; + +// What a flying shot does on touching a wall (once out of Ricochet bounces): ends, bursts into an explosion, sticks +// there (lingering, unless it's a breakable block), crawls along it, bounces off it, stops against it, passes through +// it, turns back for its fighter's hand, or latches on (as a latching shot does onto anyone it hits, see +// Weapons::hook_attach). +enum WallHit +{ + WALL_END, + WALL_BURST, + WALL_STICK, + WALL_CRAWL, + WALL_BOUNCE, + WALL_STOP, + WALL_PASS, + WALL_RETURN, + WALL_LATCH +}; +constexpr float PATH_OUT = 0.35f; +// Where an arc starts, turned back from the aim. +constexpr float ARC_START = -0.85f * PI; + +// How much of a hit's knockback lighter attacks deliver: arrows, bolts, grenades and hooks a little, rapid shots and +// beams barely. +constexpr float KNOCK_LIGHT = 0.3f; +constexpr float KNOCK_SLIGHT = 0.1f; + +// How one attack behaves, shared by every level. Unused fields stay zero. +// - 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. Knock is the share of a +// hit's knockback it delivers, and radius its hitbox's (grown with its scale). +// - Anchor and path say how it moves (see AttackAnchor and AttackPath), and shape what it hits with (see AttackShape). +// - A flying shot's physics: accelerate (u/s², up to speedup times its speed), drag (the share of its speed it keeps +// each tick), gravity (u/s², rising when negative), homing (how fast it turns toward its fighter's cursor, in +// radians a second) and wall (see WallHit). One that bounces moves as a box body across, keeping bounce of its speed +// off a surface and losing friction (u/s²) rolling on the ground. +// - Its traits, when set: +// isAligned: it faces the way it flies. +// isArming: it arms a moment after it comes to rest, for a detonator to set off (see is_armed). +// isBouncy: Ricochet bounces it off walls. +// isBursting: it bursts into an explosion when its lifetime is up (an instant, at its tip). +// isClaiming: the other fighters' shots it moves become its fighter's (see Weapons::shots_drift). +// isDrifting: a Vacuum moves and refreshes it. +// isEased: its path speeds up as it goes (from rest to twice its speed, as a swing winds up). +// isFalloff: its damage falls off. +// isInheriting: it gains the part of its fighter's speed along the aim. +// isInstant: it reaches its full range at once (see Weapons::instant_launch), hitting only as it appears. +// isMarking: it marks whoever it hits for its weapon's blasts to hit three times as hard (the double donk). +// isOrbiting: it circles its fighter in a shared orbit until launched (see Weapons::orbits_update). +// isPassingPlatforms: it flies through platforms. +// isProjectile: the Accelerator speeds it up. +// isRigid: its reach grows with its size as one rigid piece (a hammer, a Vacuum's cone). +// isShot: it ends once out of hits. +// isTethered: it's kept within its range of its fighter's hand (on a chain or rope). +// isWielded: it's the weapon itself out of the hand (a swing, a poke, a throw, a hook), which can't fire again until +// it's back. struct AttackDef { const char* label; AttackKind kind; SoundId sound; + AttackAnchor anchor = ANCHOR_WORLD; + AttackPath path = PATH_STRAIGHT; + WallHit wall = WALL_END; + AttackShape shape = SHAPE_CAPSULE; + float accelerate = 0; + float body = 0; + float bounce = 0; + float drag = 1; float duration = 0; + float friction = 0; + float gravity = 0; float hold = 0; + float homing = 0; + float knock = 1; + float radius = 0; float scale = 1; float shake = 0; + float speedup = 1; float spread = 0; float scatter = 0; int hits = 1; + bool isAligned = false; + bool isArming = false; + bool isBouncy = false; + bool isBursting = false; + bool isClaiming = false; + bool isDrifting = false; + bool isEased = false; + bool isFalloff = false; + bool isInheriting = false; + bool isInstant = false; + bool isMarking = false; + bool isOrbiting = false; + bool isPassingPlatforms = false; + bool isProjectile = false; + bool isRigid = false; + bool isShot = false; + bool isTethered = false; + bool isWielded = false; }; -// 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). +// One attack's numbers at one level. Range is in units: reach, an arc's radius, a cone's reach, or a beam or rope's +// length (see reach_get for an attack timed by a lifetime instead). // 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. @@ -73,8 +208,12 @@ struct AttackStats { float area = 0; float damage = 0; - float duration = 0; + // Seconds into its flight that a shot's damage bottoms out, for shots that fly on past it (otherwise it fades over + // its whole lifetime). + float fade = 0; float fan = 0; + // How long it lasts, for an attack timed by it. Its reach is then however far its speed carries it in that time. + float lifetime = 0; float lunge = 0; float range = 0; float scale = 1; @@ -82,6 +221,15 @@ struct AttackStats int volley = 1; }; +// How far an attack reaches: its range when it has one (an arc's radius, or how far a shot that slows or falls gets), +// else as far as its speed carries it in its lifetime (or the outward part of it, going out and back). +inline float reach_get(AttackPath path, const AttackStats& stats) +{ + if (stats.range > 0 || path == PATH_ARC) + return stats.range; + return stats.speed * stats.lifetime * (path == PATH_RETURN ? PATH_OUT : 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 @@ -95,28 +243,38 @@ struct LevelDef float reel = 0; int count = 1; int limit = 0; + + const AttackStats& stats_get(AttackVariant variant) const + { + return variant == VARIANT_CHARGED ? charged : normal; + } }; // How long a stuck arrow fades out at the end of its life, no longer a ledge to stand on. constexpr float ARROW_FADE = 0.6f; -constexpr float ARROW_GRAVITY = 500; constexpr float MINE_ARM_TIME = 0.3f; -constexpr float ROCKET_ACCELERATION = 600; -constexpr float ROCKET_SPEEDUP = 7.2f; +// A mine goes off on its own once it's been out its lifetime. Armed, it blinks MINE_BLINK apart, quickening over its +// last stretch (and beeping) down to MINE_BLINK_MIN apart, lit for MINE_BLINK_LIT of each. +constexpr float MINE_LIFETIME = 15; +constexpr float MINE_HURRY = 3; +constexpr float MINE_BLINK = 0.6f; +constexpr float MINE_BLINK_MIN = 0.1f; +constexpr float MINE_BLINK_LIT = 0.2f; enum AimStyle { AIM_DIRECT, AIM_LEAD, - AIM_LOB, - AIM_GROUND + AIM_GROUND, + AIM_FEET }; enum ChargePolicy { CHARGE_NEVER, CHARGE_SOMETIMES, - CHARGE_ALWAYS + CHARGE_ALWAYS, + CHARGE_PLANNED }; // How a bot uses a weapon: the distance band it prefers to fight from (it still fires when closer than the minimum), @@ -142,6 +300,8 @@ enum HitType HIT_FIRE, HIT_EXPLOSIVE, HIT_MAGIC, + HIT_AIR, + HIT_ELECTRIC, HIT_TYPE_COUNT }; @@ -152,28 +312,39 @@ struct HitSounds }; 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}, + {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}, {SOUND_HIT_AIR, SOUND_HIT_AIR_HEAVY}, + {SOUND_HIT_ELECTRIC, SOUND_HIT_ELECTRIC_HEAVY}, }; -inline SoundId hit_sound_get(HitType type, bool isHeavy) +inline SoundId hit_sound_get(HitType type, int damage) { - return isHeavy ? HIT_SOUNDS[type].heavy : HIT_SOUNDS[type].light; + return damage >= HEAVY_DAMAGE ? HIT_SOUNDS[type].heavy : HIT_SOUNDS[type].light; } +// How holding the attack fires a weapon: again each time its cooldown runs out, drawn (blending its normal and charged +// numbers by how far the charge got, instead of switching to the charged attack only when full), or continuously (one +// attack per hand kept going for as long as it's held, see Weapons::continuous_get). +enum FireMode +{ + FIRE_REPEAT, + FIRE_DRAWN, + FIRE_CONTINUOUS +}; + // 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. +// before settling back. Mode is how holding the attack fires it. A charge-scaled weapon's charge just makes the same +// shot stronger the further it goes (a Bow's draw, a Grenade Launcher's fuse burning down), rather than building to a +// different attack. 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; @@ -183,518 +354,929 @@ struct WeaponDef AttackDef charged; LevelDef levels[LEVEL_COUNT]; BotHint bot; - bool isDrawn = false; + FireMode mode = FIRE_REPEAT; + bool isChargeScaled = false; bool isEjecting = false; bool isReloading = false; + + const AttackDef& attack_get(AttackVariant variant) const + { + return variant == VARIANT_CHARGED ? charged : normal; + } }; -inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = +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.075f, - .spread = 7}, - .charged = {.label = "flying slash", - .kind = ATTACK_SLASH, - .sound = SOUND_SLASH_HEAVY, - .duration = 0.6f, - .shake = 0.225f, - .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.15f, - .spread = 6}, - .charged = {.label = "slam", - .kind = ATTACK_SWING, - .sound = SOUND_SWING_HEAVY, - .duration = 0.45f, - .shake = 0.35f, - .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.075f, .spread = 4}, - .charged = - {.label = "lunging poke", .kind = ATTACK_POKE, .sound = SOUND_POKE_HEAVY, .shake = 0.2f, .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.025f, - .spread = 18}, - .charged = {.label = "uppercut", - .kind = ATTACK_UPPERCUT, - .sound = SOUND_UPPERCUT, - .duration = 0.35f, - .shake = 0.25f, - .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.075f, - .spread = 8, - .hits = 99}, - .charged = {.label = "ball throw", - .kind = ATTACK_FLAIL, - .sound = SOUND_FLAIL_HEAVY, - .shake = 0.15f, - .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.05f, - .spread = 7.5f}, - .charged = {.label = "lash + blast", - .kind = ATTACK_WHIP, - .sound = SOUND_WHIP_HEAVY, - .duration = 0.12f, - .shake = 0.15f, - .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.1f, .spread = 2}, - .charged = {.label = "yanking hook", - .kind = ATTACK_HOOK, - .sound = SOUND_GRAPPLE_HEAVY, - .shake = 0.2f, - .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.075f, - .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.15f, - .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.025f, .spread = 6}, - .charged = - {.label = "drawn arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW_HEAVY, .shake = 0.15f, .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.01f, .spread = 7}, - .charged = {.label = "bullet", - .kind = ATTACK_BULLET, - .sound = SOUND_UZI_HEAVY, - .shake = 0.125f, - .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.15f, - .spread = 6, - .scatter = 2.3f}, - .charged = {.label = "slug", - .kind = ATTACK_BULLET, - .sound = SOUND_SHOTGUN_HEAVY, - .scale = 2.5f, - .shake = 0.25f, - .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.04f, .spread = 2}, - .charged = {.label = "beam", - .kind = ATTACK_BEAM, - .sound = SOUND_BEAM, - .duration = 0.3f, - .shake = 0.175f, - .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, with " - "an airblast\nthat shoves fighters near you away.", - .flavor = "Mmmph mmmph mmph!!", - .hit = HIT_FIRE, - .kick = -0.1f, - .normal = {.label = "flame", - .kind = ATTACK_FLAME, - .sound = SOUND_FLAME, - .shake = 0.005f, - .spread = 16, - .hits = 2}, - .charged = - {.label = "fireball", .kind = ATTACK_FIREBALL, .sound = SOUND_FIREBALL, .shake = 0.2f, .spread = 6}, - .levels = - { - {.normal = {.damage = 2, .duration = 1.5f, .range = 115, .speed = 500}, - .charged = {.area = 40, .damage = 30, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, - .cooldown = 0.08f}, - {.normal = {.damage = 3, .duration = 2.25f, .range = 140, .speed = 650}, - .charged = {.area = 55, .damage = 42, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, - .cooldown = 0.08f}, - {.normal = {.damage = 4, .duration = 3, .range = 165, .speed = 800}, - .charged = {.area = 70, .damage = 54, .lunge = -165, .range = 200, .scale = 1.5f, .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.075f, .spread = 5}, - .charged = - {.label = "missile", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET_HEAVY, .shake = 0.15f, .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.05f, - .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.025f, - .spread = 4}, - .charged = {.label = "teleport", - .kind = ATTACK_TELEPORT, - .sound = SOUND_TELEPORT_WARP, - .duration = 0.175f, - .shake = 0.15f}, - .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}, - }, + .name = "Fist", + .blurb = "Throws a rapid flurry of punches\nwhile held.\n\nCharge to launch yourself\nwith a huge uppercut.", + .flavor = "ORA ORA ORA ORA ORA", + .hit = HIT_BLUNT, + .normal = {.label = "punch", + .kind = ATTACK_PUNCH, + .sound = SOUND_PUNCH, + .anchor = ANCHOR_HAND, + .radius = 6, + .shake = 0.025f, + .spread = 18, + .isProjectile = true}, + .charged = {.label = "uppercut", + .kind = ATTACK_UPPERCUT, + .sound = SOUND_UPPERCUT, + .anchor = ANCHOR_BODY, + .radius = 14, + .shake = 0.25f, + .spread = 5}, + .levels = + { + {.normal = {.damage = 14, .lifetime = 0.12f, .speed = 283}, + .charged = {.damage = 50, .lifetime = 0.35f, .lunge = 420, .scale = 1}, + .cooldown = 0.1f}, + {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 333}, + .charged = {.damage = 69, .lifetime = 0.35f, .lunge = 500, .scale = 1.4f}, + .cooldown = 0.07f}, + {.normal = {.damage = 19, .lifetime = 0.12f, .speed = 383}, + .charged = {.damage = 88, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f}, + .cooldown = 0.045f}, + }, + .bot = {.rangeMax = 36}, + }, + { + .name = "Sword", + .blurb = "Shoot a wide slash.\n\nCharge 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, + .wall = WALL_PASS, + .radius = 14, + .shake = 0.075f, + .spread = 7, + .isAligned = true, + .isBouncy = true, + .isInheriting = true, + .isProjectile = true}, + .charged = {.label = "flying slash", + .kind = ATTACK_SLASH, + .sound = SOUND_SLASH_HEAVY, + .wall = WALL_PASS, + .radius = 14, + .shake = 0.225f, + .spread = 5, + .isAligned = true, + .isBouncy = true, + .isInheriting = true, + .isProjectile = true}, + .levels = + { + {.normal = {.damage = 18, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, + .charged = {.damage = 36, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, + .cooldown = 0.3f}, + {.normal = {.damage = 24, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, + .charged = {.damage = 48, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, + .cooldown = 0.3f}, + {.normal = {.damage = 30, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, + .charged = {.damage = 60, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, + .cooldown = 0.3f}, + }, + .bot = {.rangeMax = 48}, + }, + { + .name = "Hammer", + .blurb = "Swings overhead in a wide arc.\n\nCharge to grow the hammer\nand slam down hard.\n\nSlams can send " + "you flying.", + .flavor = "Stop! Hammer time!", + .hit = HIT_BLUNT, + .normal = {.label = "swing", + .kind = ATTACK_SWING, + .sound = SOUND_SWING, + .anchor = ANCHOR_HAND, + .path = PATH_ARC, + .radius = 8, + .shake = 0.15f, + .spread = 6, + .isEased = true, + .isProjectile = true, + .isRigid = true, + .isWielded = true}, + .charged = {.label = "slam", + .kind = ATTACK_SWING, + .sound = SOUND_SWING_HEAVY, + .anchor = ANCHOR_HAND, + .path = PATH_ARC, + .radius = 8, + .shake = 0.35f, + .spread = 6, + .isEased = true, + .isProjectile = true, + .isRigid = true, + .isWielded = true}, + .levels = + { + {.normal = {.damage = 20, .lifetime = 0.3f, .range = 21.43f, .scale = 1.07f, .speed = 258}, + .charged = {.damage = 40, .lifetime = 0.45f, .range = 45.71f, .scale = 2.29f, .speed = 367}, + .cooldown = 0.5f}, + {.normal = {.damage = 28, .lifetime = 0.3f, .range = 24.29f, .scale = 1.21f, .speed = 292}, + .charged = {.damage = 55, .lifetime = 0.45f, .range = 51.43f, .scale = 2.57f, .speed = 413}, + .cooldown = 0.5f}, + {.normal = {.damage = 34, .lifetime = 0.3f, .range = 27.14f, .scale = 1.36f, .speed = 327}, + .charged = {.damage = 69, .lifetime = 0.45f, .range = 57.14f, .scale = 2.86f, .speed = 459}, + .cooldown = 0.5f}, + }, + .bot = {.rangeMax = 36}, + }, + { + .name = "Lance", + .blurb = "Pokes a distance ahead of you.\n\nCharge to lunge ahead\nwith a harder, further\ntravelling poke.", + .flavor = "Works best when riding an ostrich.", + .hit = HIT_PIERCE, + .normal = {.label = "poke", + .kind = ATTACK_POKE, + .sound = SOUND_POKE, + .anchor = ANCHOR_HAND, + .path = PATH_RETURN, + .radius = 3, + .shake = 0.075f, + .spread = 4, + .isProjectile = true, + .isWielded = true}, + .charged = {.label = "lunging poke", + .kind = ATTACK_POKE, + .sound = SOUND_POKE_HEAVY, + .anchor = ANCHOR_HAND, + .path = PATH_RETURN, + .radius = 3, + .shake = 0.2f, + .spread = 3, + .isProjectile = true, + .isWielded = true}, + .levels = + { + {.normal = {.damage = 15, .lifetime = 0.29f, .speed = 890}, + .charged = {.damage = 45, .lifetime = 0.29f, .lunge = 200, .speed = 890}, + .cooldown = 0.35f}, + {.normal = {.damage = 20, .lifetime = 0.31f, .speed = 970}, + .charged = {.damage = 60, .lifetime = 0.31f, .lunge = 250, .speed = 970}, + .cooldown = 0.35f}, + {.normal = {.damage = 25, .lifetime = 0.33f, .speed = 1040}, + .charged = {.damage = 75, .lifetime = 0.33f, .lunge = 300, .speed = 1040}, + .cooldown = 0.35f}, + }, + .bot = {.rangeMin = 40, .rangeMax = 140}, + }, + { + .name = "Whip", + .blurb = "Send an whip out ahead\nof you, with a wide arc.\n\nCharge to launch a devastating,\nhard-to-hit " + "whip crack.", + .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, + .anchor = ANCHOR_HAND, + .path = PATH_RETURN, + .radius = 4, + .shake = 0.05f, + .spread = 7.5f, + .isProjectile = true}, + .charged = {.label = "lash + blast", + .kind = ATTACK_WHIP, + .sound = SOUND_WHIP_HEAVY, + .path = PATH_OWN, + .duration = 0.12f, + .radius = 4, + .shake = 0.15f, + .spread = 0.5f, + .hits = 0, + .isBursting = true, + .isInstant = true}, + .levels = + { + {.normal = {.damage = 18, .lifetime = 0.38f, .speed = 950}, + .charged = {.area = 6, .damage = 42, .range = 125}, + .cooldown = 0.45f}, + {.normal = {.damage = 21, .lifetime = 0.38f, .speed = 1175}, + .charged = {.area = 7, .damage = 63, .range = 155}, + .cooldown = 0.45f}, + {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 1400}, + .charged = {.area = 8, .damage = 84, .range = 185}, + .cooldown = 0.45f}, + }, + .bot = {.rangeMin = 90, .rangeMax = 160}, + }, + { + .name = "Flail", + .blurb = "Spin a spiked ball around you.\n\nCharge 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, + .anchor = ANCHOR_BODY, + .path = PATH_ARC, + .radius = 9, + .shake = 0.075f, + .spread = 8, + .hits = HITS_ENDLESS, + .isProjectile = true, + .isWielded = true}, + .charged = {.label = "ball throw", + .kind = ATTACK_FLAIL, + .sound = SOUND_FLAIL_HEAVY, + .wall = WALL_BOUNCE, + .body = 12, + .bounce = 0.3f, + .friction = 600, + .gravity = 1000, + .radius = 9, + .shake = 0.15f, + .spread = 6, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isDrifting = true, + .isInheriting = true, + .isProjectile = true, + .isTethered = true, + .isWielded = true}, + .levels = + { + {.normal = {.damage = 13, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, + .charged = {.damage = 40, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450}, + .cooldown = 0.5f}, + {.normal = {.damage = 18, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, + .charged = {.damage = 50, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450}, + .cooldown = 0.5f}, + {.normal = {.damage = 22, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, + .charged = {.damage = 60, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, + .cooldown = 0.5f}, + }, + .bot = {.rangeMax = 60}, + }, + { + .name = "Boomerang", + .blurb = "Throws a boomerang that will\nreturn to you.\n\nCharge to throw a huge one that\ngoes further and " + "stays put for\nawhile.\n\nBoomerangs hit continuously\nwhile they're out.", + .flavor = "An Australian favorite.", + .hit = HIT_BLUNT, + .normal = {.label = "boomerang", + .kind = ATTACK_BOOMERANG, + .sound = SOUND_BOOMERANG, + .path = PATH_RETURN, + .wall = WALL_RETURN, + .drag = 0.905f, + .radius = 9, + .shake = 0.075f, + .spread = 8, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isDrifting = true, + .isProjectile = true, + .isWielded = true}, + .charged = {.label = "big boomerang", + .kind = ATTACK_BOOMERANG, + .sound = SOUND_BOOMERANG_HEAVY, + .path = PATH_RETURN, + .wall = WALL_STOP, + .drag = 0.917f, + .hold = 1.2f, + .radius = 9, + .scale = 2, + .shake = 0.15f, + .spread = 6, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isDrifting = true, + .isProjectile = true, + .isWielded = true}, + .levels = + { + {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + .cooldown = 0.2f, + .count = 1}, + {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + .cooldown = 0.2f, + .count = 2}, + {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + .cooldown = 0.2f, + .count = 3}, + }, + .bot = {.rangeMin = 40, .rangeMax = 120}, + }, + { + .name = "Grapple", + .blurb = "Fires a hook that latches on\nsurfaces or combatants.\n\nCharge to plunge the hook in\ninstantly and " + "get yanked to it.\n\nSwing around or reel and loosen\nthe hook with movement.", + .flavor = "Get over here!", + .hit = HIT_PIERCE, + .normal = {.label = "hook", + .kind = ATTACK_HOOK, + .sound = SOUND_GRAPPLE, + .wall = WALL_LATCH, + .knock = KNOCK_LIGHT, + .radius = 6, + .shake = 0.1f, + .spread = 2, + .isAligned = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isTethered = true, + .isWielded = true}, + .charged = {.label = "yanking hook", + .kind = ATTACK_HOOK, + .sound = SOUND_GRAPPLE_HEAVY, + .wall = WALL_LATCH, + .knock = KNOCK_LIGHT, + .radius = 6, + .shake = 0.2f, + .spread = 1, + .isAligned = true, + .isDrifting = true, + .isFalloff = true, + .isInstant = true, + .isProjectile = true, + .isTethered = true, + .isWielded = true}, + .levels = + { + {.normal = {.damage = 16, .range = 155, .speed = 600}, + .charged = {.damage = 32, .lifetime = 0.1f, .range = 250, .speed = 400}, + .cooldown = 0.85f, + .reel = 120}, + {.normal = {.damage = 22, .range = 155, .speed = 700}, + .charged = {.damage = 45, .lifetime = 0.1f, .range = 300, .speed = 450}, + .cooldown = 0.85f, + .reel = 135}, + {.normal = {.damage = 28, .range = 155, .speed = 800}, + .charged = {.damage = 58, .lifetime = 0.1f, .range = 350, .speed = 500}, + .cooldown = 0.85f, + .reel = 150}, + }, + .bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD}, + }, + { + .name = "Bow", + .blurb = "Shoot arrows affected by gravity.\n\nCharge will make the arrows fly\nfurther, faster and hit " + "harder.\n\nEmbedded arrows\ncan be used as platforms.\n\nAdditional levels unleash more\narrows.", + .flavor = "Erm, actually, bows require\nimmense upper body strengt-", + .hit = HIT_PIERCE, + .normal = {.label = "arrow", + .kind = ATTACK_ARROW, + .sound = SOUND_BOW, + .wall = WALL_STICK, + .gravity = 500, + .knock = KNOCK_LIGHT, + .radius = 6, + .shake = 0.025f, + .spread = 6, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .charged = {.label = "drawn arrow", + .kind = ATTACK_ARROW, + .sound = SOUND_BOW_HEAVY, + .wall = WALL_STICK, + .gravity = 500, + .knock = KNOCK_LIGHT, + .radius = 6, + .shake = 0.15f, + .spread = 3, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .levels = + { + {.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, + .charged = {.damage = 22, .range = 500, .speed = 560, .volley = 1}, + .cooldown = 0.6f}, + {.normal = {.damage = 22, .range = 360, .speed = 360, .volley = 2}, + .charged = {.damage = 30, .range = 500, .speed = 670, .volley = 2}, + .cooldown = 0.6f}, + {.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.6f}, + }, + .bot = {.rangeMin = 100, .rangeMax = 260, .charge = CHARGE_ALWAYS, .aim = AIM_LEAD}, + .mode = FIRE_DRAWN, + .isChargeScaled = true, + }, + { + .name = "Blaster", + .blurb = "Fires fast-flying laser beams\nwith infinite range.\n\nCharge to fire an\ninstantaneous beam.", + .flavor = "*pew pew pew*", + .hit = HIT_ENERGY, + .flash = 1, + .kick = -0.5f, + .normal = {.label = "bolt", + .kind = ATTACK_PROJECTILE, + .sound = SOUND_BLAST, + .knock = KNOCK_SLIGHT, + .radius = 4, + .shake = 0.04f, + .spread = 2, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .charged = {.label = "beam", + .kind = ATTACK_BEAM, + .sound = SOUND_BEAM, + .path = PATH_OWN, + .duration = 0.3f, + .knock = KNOCK_SLIGHT, + .radius = 4, + .shake = 0.175f, + .spread = 0.5f, + .isBouncy = true, + .isFalloff = true, + .isInstant = true}, + .levels = + { + {.normal = {.damage = 10, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 12, .lunge = -120, .range = RANGE_INFINITE}, + .cooldown = 0.2f}, + {.normal = {.damage = 13, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 24, .lunge = -120, .range = RANGE_INFINITE}, + .cooldown = 0.2f}, + {.normal = {.damage = 16, .fade = 0.64f, .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 = "Uzi", + .blurb = "Sprays tiny, fast bullets.\n\nCharge to spray a wide volley\nof bullets.", + .flavor = "Packin' heat!", + .hit = HIT_BULLET, + .flash = 0.7f, + .kick = -0.15f, + .normal = {.label = "bullet", + .kind = ATTACK_BULLET, + .sound = SOUND_UZI, + .knock = KNOCK_SLIGHT, + .radius = 2, + .shake = 0.01f, + .spread = 7, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .charged = {.label = "bullet", + .kind = ATTACK_BULLET, + .sound = SOUND_UZI_HEAVY, + .knock = KNOCK_SLIGHT, + .radius = 2, + .shake = 0.125f, + .spread = 6, + .scatter = 1.1f, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .levels = + { + {.normal = {.damage = 2, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 4, + .fade = 0.45f, + .fan = 0.2f, + .lunge = -60, + .range = RANGE_INFINITE, + .speed = 1200, + .volley = 5}, + .cooldown = 0.12f}, + {.normal = {.damage = 4, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 8, + .fade = 0.45f, + .fan = 0.2f, + .lunge = -60, + .range = RANGE_INFINITE, + .speed = 1200, + .volley = 7}, + .cooldown = 0.075f}, + {.normal = {.damage = 6, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 12, + .fade = 0.45f, + .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-ranged slug\nahead of you\nwith high recoil.\n\nCharge to fire a heavy slug\nthat " + "pierces combatants, flies\nfurther, and has higher recoil.", + .flavor = "This...is my BOOMSTICK!", + .hit = HIT_BULLET, + .flash = 1.5f, + .kick = -0.8f, + .normal = {.label = "pellet", + .kind = ATTACK_BULLET, + .sound = SOUND_SHOTGUN, + .knock = KNOCK_SLIGHT, + .radius = 2, + .shake = 0.15f, + .spread = 6, + .scatter = 2.3f, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .charged = {.label = "slug", + .kind = ATTACK_BULLET, + .sound = SOUND_SHOTGUN_HEAVY, + .knock = KNOCK_SLIGHT, + .radius = 2, + .scale = 2.5f, + .shake = 0.25f, + .spread = 4, + .hits = 3, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true, + .isShot = true}, + .levels = + { + {.normal = {.damage = 6, .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 = 8, .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 = 10, .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 = "Bolter", + .blurb = "Shoots a spark that\nwill travel along surfaces,\nincluding walls and ceilings.\n\nCharge to unleash " + "a larger,\nlonger-lasting, bigger spark.", + .flavor = "ZAP, MUTHAFUCKA!", + .hit = HIT_ELECTRIC, + .flash = 0.4f, + .kick = -0.3f, + .normal = {.label = "bolt", + .kind = ATTACK_BOLT, + .sound = SOUND_BOLTER, + .wall = WALL_CRAWL, + .gravity = 600, + .knock = KNOCK_LIGHT, + .radius = 3, + .shake = 0.04f, + .spread = 4, + .hits = HITS_ENDLESS, + .isAligned = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .charged = {.label = "big bolt", + .kind = ATTACK_BOLT, + .sound = SOUND_BOLTER_HEAVY, + .wall = WALL_CRAWL, + .gravity = 1200, + .knock = KNOCK_LIGHT, + .radius = 3, + .scale = 2, + .shake = 0.15f, + .spread = 2, + .hits = HITS_ENDLESS, + .isAligned = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .levels = + { + {.normal = {.damage = 10, .range = 660, .speed = 260}, + .charged = {.damage = 24, .range = 660, .speed = 220}, + .cooldown = 0.6f}, + {.normal = {.damage = 13, .range = 840, .speed = 260}, + .charged = {.damage = 32, .range = 840, .speed = 220}, + .cooldown = 0.6f}, + {.normal = {.damage = 16, .range = 1020, .speed = 260}, + .charged = {.damage = 40, .range = 1020, .speed = 220}, + .cooldown = 0.6f}, + }, + .bot = {.rangeMin = 30, .rangeMax = 200, .aim = AIM_FEET}, + }, + { + .name = "Rocket Launcher", + .blurb = "Fires an accelerating rocket\nthat will explode on impact.\n\nCharge to fire a big, slow " + "missile\nwith a wider blast.\n\nThe rockets' explosions\nwill launch you.", + .flavor = "RAWKET LAWNCHAIR", + .hit = HIT_EXPLOSIVE, + .kick = -0.6f, + .normal = {.label = "rocket", + .kind = ATTACK_ROCKET, + .sound = SOUND_ROCKET, + .wall = WALL_BURST, + .accelerate = 600, + .radius = 4, + .shake = 0.075f, + .speedup = 7.2f, + .spread = 5, + .isAligned = true, + .isBouncy = true, + .isBursting = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .charged = {.label = "missile", + .kind = ATTACK_ROCKET, + .sound = SOUND_ROCKET_HEAVY, + .wall = WALL_BURST, + .accelerate = 600, + .radius = 4, + .shake = 0.15f, + .speedup = 7.2f, + .spread = 4, + .isAligned = true, + .isBouncy = true, + .isBursting = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .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 = "Grenade Launcher", + .blurb = "Lobs a bouncing grenade, which\nwill explode after some time.\n\nCharge to cook the grenade's " + "fuse,\nup to exploding instantly at close\nrange.\n\nThe explosion will deal additional\ndamage when " + "the grenade contacts\nand explodes in a short time frame.", + .flavor = "Double donk!", + .hit = HIT_EXPLOSIVE, + .kick = -0.4f, + .normal = {.label = "grenade", + .kind = ATTACK_GRENADE, + .sound = SOUND_GRENADE, + .wall = WALL_BOUNCE, + .body = 6, + .bounce = 0.55f, + .friction = 300, + .gravity = 750, + .knock = KNOCK_LIGHT, + .radius = 3, + .shake = 0.1f, + .spread = 3, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isBursting = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isMarking = true, + .isProjectile = true}, + .charged = {.label = "point-blank blast", + .kind = ATTACK_GRENADE, + .sound = SOUND_NONE, + .wall = WALL_BOUNCE, + .body = 6, + .bounce = 0.55f, + .friction = 300, + .gravity = 750, + .knock = KNOCK_LIGHT, + .radius = 3, + .shake = 0.2f, + .spread = 3, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isBursting = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .levels = + { + {.normal = {.area = 32, .damage = 30, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 32, .damage = 30, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.9f}, + {.normal = {.area = 38, .damage = 38, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 38, .damage = 38, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.9f}, + {.normal = {.area = 44, .damage = 46, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 44, .damage = 46, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.9f}, + }, + .bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_PLANNED, .aim = AIM_LEAD}, + .isChargeScaled = true, + }, + { + .name = "Mine", + .blurb = "Charge to throw a floating mine\nout ahead of you.\n\nUse a basic attack to detonate all\nactive " + "mines at once.\n\nThe mines' explosions\nwill launch you.", + .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, .path = PATH_OWN}, + .charged = {.label = "mine", + .kind = ATTACK_MINE, + .sound = SOUND_MINE, + .wall = WALL_STOP, + .drag = 0.9f, + .shake = 0.05f, + .spread = 6, + .hits = 0, + .isArming = true, + .isBouncy = true, + .isBursting = true, + .isDrifting = true}, + .levels = + { + {.normal = {.area = 56, .damage = 55, .range = 120}, + .charged = {.area = 56, .damage = 55, .lifetime = 15, .range = 120, .speed = 720}, + .cooldown = 0.3f, + .count = 2}, + {.normal = {.area = 66, .damage = 77, .range = 120}, + .charged = {.area = 66, .damage = 77, .lifetime = 15, .range = 120, .speed = 720}, + .cooldown = 0.3f, + .count = 4}, + {.normal = {.area = 76, .damage = 99, .range = 120}, + .charged = {.area = 76, .damage = 99, .lifetime = 15, .range = 120, .speed = 720}, + .cooldown = 0.3f, + .count = 6}, + }, + .bot = {.rangeMax = 120, .charge = CHARGE_ALWAYS}, + }, + { + .name = "Flamethrower", + .blurb = "Sprays flames that drift up and\nlinger, burning whatever they touch.\n\nCharge to hurl a giant " + "fireball\nthat explodes on impact or after\na short distance.\n\nThe fireball's explosion " + "will\nlaunch you.", + .flavor = "Mmmph mmmph mmph!!", + .hit = HIT_FIRE, + .kick = -0.1f, + .normal = {.label = "flame", + .kind = ATTACK_FLAME, + .sound = SOUND_FLAME, + .wall = WALL_STOP, + .drag = 0.9f, + .gravity = -100, + .radius = 5, + .shake = 0.005f, + .spread = 16, + .hits = HITS_ENDLESS, + .isBouncy = true, + .isDrifting = true, + .isInheriting = true, + .isProjectile = true}, + .charged = {.label = "fireball", + .kind = ATTACK_FIREBALL, + .sound = SOUND_FIREBALL, + .wall = WALL_BURST, + .radius = 5, + .shake = 0.2f, + .spread = 6, + .isAligned = true, + .isBouncy = true, + .isBursting = true, + .isDrifting = true, + .isFalloff = true, + .isInheriting = true, + .isProjectile = true}, + .levels = + { + {.normal = {.damage = 1.5f, .lifetime = 1.5f, .range = 115, .speed = 500}, + .charged = {.area = 40, .damage = 30, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, + .cooldown = 0.08f}, + {.normal = {.damage = 2.25f, .lifetime = 2.25f, .range = 140, .speed = 650}, + .charged = {.area = 55, .damage = 42, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, + .cooldown = 0.08f}, + {.normal = {.damage = 3, .lifetime = 3, .range = 165, .speed = 800}, + .charged = {.area = 70, .damage = 54, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, + .cooldown = 0.08f}, + }, + .bot = {.rangeMax = 110}, + }, + { + .name = "Vacuum", + .blurb = "Hold to suck combatants and\nprojectiles in a cone. Your\nprojectiles will be refreshed\nwhile " + "contained in the vortex.\n\nCharge to unleash a compression\nblast in a cone that will\nsend " + "combatants and\nprojectiles flying, turning\nenemy projectiles into yours.", + .flavor = "Who ya gonna call?", + .hit = HIT_AIR, + .kick = -0.1f, + .normal = {.label = "suck", .kind = ATTACK_VACUUM, .sound = SOUND_NONE, .anchor = ANCHOR_HAND, .path = PATH_OWN, .shape = SHAPE_CONE, .knock = 0, .hits = 0, .isRigid = true}, + .charged = {.label = "airblast", .kind = ATTACK_GUST, .sound = SOUND_VACUUM_HEAVY, .anchor = ANCHOR_HAND, .path = PATH_OWN, .shape = SHAPE_CONE, .knock = 0, .shake = 0.15f, .hits = 0, .isClaiming = true, .isRigid = true}, + .levels = + { + {.normal = {.damage = 2, .range = 110, .speed = 160}, + .charged = {.damage = 6, .lifetime = 0.2f, .lunge = -120, .range = 110, .speed = 2100}, + .cooldown = 0.2f}, + {.normal = {.damage = 3, .range = 135, .speed = 190}, + .charged = {.damage = 9, .lifetime = 0.2f, .lunge = -120, .range = 135, .speed = 2600}, + .cooldown = 0.2f}, + {.normal = {.damage = 5, .range = 160, .speed = 220}, + .charged = {.damage = 15, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100}, + .cooldown = 0.2f}, + }, + .bot = {.rangeMax = 110, .charge = CHARGE_ALWAYS}, + .mode = FIRE_CONTINUOUS, + }, + { + .name = "Grimoire", + .blurb = "Hold to conjure wisps\nthat will orbit around you.\nRelease to unleash them.\nThe wisps will slowly " + "follow\nyour aim.\n\nCharge to prime a teleport\nat a distance that will spawn\nan explosion 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, + .homing = 1.75f, + .radius = 4, + .shake = 0.025f, + .spread = 4, + .isAligned = true, + .isBouncy = true, + .isDrifting = true, + .isFalloff = true, + .isOrbiting = true, + .isPassingPlatforms = true, + .isProjectile = true, + .isShot = true}, + .charged = {.label = "teleport", + .kind = ATTACK_TELEPORT, + .sound = SOUND_TELEPORT_WARP, + .path = PATH_OWN, + .duration = 0.175f, + .shake = 0.15f}, + .levels = + { + {.normal = {.damage = 12, .range = 320, .speed = 248, .volley = 1}, + .charged = {.area = 30, .damage = 20, .range = 220}, + .cooldown = 0.7f, + .limit = 3}, + {.normal = {.damage = 14, .range = 320, .speed = 263, .volley = 2}, + .charged = {.area = 40, .damage = 30, .range = 250}, + .cooldown = 0.7f, + .limit = 6}, + {.normal = {.damage = 15, .range = 320, .speed = 278, .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 @@ -722,14 +1304,19 @@ struct AttackSummary bool isPiecesChanged = false; }; -AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool isCharged); +AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, AttackVariant variant); // 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.25f}; +inline constexpr AttackDef EXPLOSION_DEF = {.kind = ATTACK_EXPLOSION, + .path = PATH_OWN, + .shape = SHAPE_CIRCLE, + .duration = 0.35f, + .shake = 0.25f, + .isInstant = true}; // 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. +// left, and each ricochet it has made adds a quarter of 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 @@ -752,42 +1339,81 @@ struct Attack float aimAngle = 0; float angle = 0; float anglePrevious = 0; + // An armed mine's countdown to its next blink. + float blink = 0; float bonus = 1; float duration = 0; + // When a shot's damage bottoms out, and when it ends, in seconds of flight (see life_get). + float fade = 0; float hitCooldown = 0; float length = 0; + // When it was claimed from another fighter, whose colour it turns from (see Weapons::shots_drift). + float claimTime = 0; + float lifeStart = 0; + float lifetime = 0; float orbitAngle = 0; float orbitRadius = 0; float radius = 0; + // When an arming attack came to rest (see is_armed), never until it has. + float restTime = LIFETIME_INFINITE; float scale = 1; + // A crawling bolt's side its surface is on: 1 for its right (as it heads), -1 for its left. + float side = 0; + // A bouncing shot's slope under it, carried between ticks with whether it's on the ground (see + // Weapons::body_bounce). + float slope = 0; float slot = 0; float time = 0; float timePrevious = 0; - float travelled = 0; int bounces = 0; int hitsLeft = 0; int owner = 0; + int ownerPrevious = -1; int ricochets = 0; + // Targets already hit, a bit each: by a grenade's clonks, or by a Vacuum's nibble this interval. + Uint32 struck = 0; int weapon = 0; int key = -1; // The hand it came from: the main hand (0) or a Dual Wield off-hand (1 up). int hand = 0; + AttackVariant variant = VARIANT_NORMAL; bool isAttached = false; - bool isCharged = false; bool isCopy = false; bool isDead = false; + // A grenade blast that landed a double donk, which lingers flashing. + bool isDonked = false; bool isExplosion = false; bool isGathering = false; + bool isGrounded = false; bool isGentle = false; + // In a Vacuum's suck this tick, floating free of gravity and glancing off walls. + bool isHeld = false; bool isLeft = false; bool isPulling = false; bool isRebounded = false; bool isReturning = false; bool isSecondary = false; + bool is_charged() const + { + return variant == VARIANT_CHARGED; + } + + // How long it's been on its current flight: since it came, or since its last ricochet (which starts it afresh). + float life_get() const + { + return time - lifeStart; + } + + // How far through its life it is: by its lifetime, for an attack timed by one, else by its duration. + float progress_get() const + { + return life_get() / (stats.lifetime > 0 ? lifetime : duration); + } + const AttackDef* def() const { - return isExplosion ? &EXPLOSION_DEF : isCharged ? &WEAPON_DEFS[weapon].charged : &WEAPON_DEFS[weapon].normal; + return isExplosion ? &EXPLOSION_DEF : &WEAPON_DEFS[weapon].attack_get(variant); } }; @@ -800,7 +1426,8 @@ struct Capsule }; // How attacks weaken and fill in, and the shapes they hit with, shared with drawing them. -float falloff_at(float distance); +float falloff_at(float fraction); +float falloff_reach_get(Attack& attack); float blast_area_get(Attack& attack); float strength_get(Attack& attack, Vec2 point); float fill_get(float strength); @@ -834,17 +1461,60 @@ struct Blast SoundId sound; int weapon = -1; bool isGentle = false; - bool isHarmless = 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) +// The Vacuum's cone, in radians either side of the aim, the same for its suck and its airblast, and how much of its +// strength is left at the far end (full at the muzzle, easing down linearly). +constexpr float VACUUM_CONE = 0.18f; +constexpr float VACUUM_EDGE = 0.25f; + +// How long a shot launched at speed takes to fly distance, speeding up to its top speed if it accelerates. +float flight_time_get(const AttackDef& def, float speed, float distance); + +inline bool is_cone(const AttackDef& def) { - return attack.def()->kind == ATTACK_MINE && attack.time >= attack.duration + MINE_ARM_TIME; + return def.shape == SHAPE_CONE; } +// How wide an attack's cone is (radians either side of its aim), or 0 for one without. +inline float cone_get(Attack& attack) +{ + return is_cone(*attack.def()) ? VACUUM_CONE : 0; +} + +// How far an attack's cone reaches right now: a Vacuum's suck grows out as it spins up (its length, 0 to 1) and +// recedes as it winds down. +inline float cone_reach_get(Attack& attack) +{ + return attack.stats.range * (attack.def()->kind == ATTACK_VACUUM ? attack.length : 1); +} + +// How strongly an attack's cone acts at a point in it: full at its position, down to VACUUM_EDGE at its reach. +inline float cone_strength_get(Attack& attack, Vec2 point) +{ + float edge = std::min(1.0f, vec2_length(point - attack.position) / std::max(1.0f, cone_reach_get(attack))); + return 1 - edge * (1 - VACUUM_EDGE); +} + +// Whether a point lies in an attack's cone, from its position along its aim out to its range. +inline bool is_in_cone(Attack& attack, Vec2 point) +{ + Vec2 delta = point - attack.position; + return vec2_length(delta) < cone_reach_get(attack) && + std::abs(std::remainder(vec2_angle(delta) - attack.aimAngle, 2 * PI)) <= cone_get(attack); +} + +// An arming attack is armed, and can be set off, a moment after it comes to rest. +inline bool is_armed(Attack& attack) +{ + return attack.def()->isArming && attack.time >= attack.restTime + MINE_ARM_TIME; +} + +// How far apart an armed mine's blinks are right now: steady, then quicker over its last stretch. +float mine_blink_get(Attack& attack); + // 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 @@ -868,11 +1538,13 @@ struct Echo }; // One line the held weapon's attack would follow if fired now, through its steps, and the blast it would ring where -// it ends (zero area for none). +// it ends (zero area for none). A cone (radians either side, zero for none) spreads from the first step to the last +// instead. struct Path { std::vector steps; float area = 0; + float cone = 0; }; // Owns every attack in the world, spawns attacks from fighters and resolves @@ -892,14 +1564,14 @@ public: float buff_get(Fighter& fighter); void blast_spawn(Sim& sim, Blast blast); Attack* hook_get(Fighter& fighter); + Attack* continuous_get(Fighter& fighter, int key, int hand); void attacks_release(Fighter& fighter, AttackKind kind); bool is_recalled(Sim& sim, Fighter& fighter); bool is_busy(Fighter& fighter, int key); bool is_spinning(Fighter& fighter); void damage_deal(Sim& sim, Damage damage); void contact_update(Sim& sim, Fighter& fighter); - void wisp_move(Sim& sim, Attack& attack); - void wisps_release(Fighter& fighter); + void orbits_release(Fighter& fighter); void teleport_move(Sim& sim, Attack& attack); void solids_add(Fighter& fighter, World& world); @@ -908,15 +1580,23 @@ private: bool is_spawned(Sim& sim, Fighter& fighter, Shot shot); void attack_move(Sim& sim, Attack& attack); + void path_move(Sim& sim, Attack& attack); + void flight_move(Sim& sim, Attack& attack); + void wall_hit(Sim& sim, Attack& attack, Vec2 wall, bool isHeld); void attack_remember(Attack& attack); void attack_hit(Sim& sim, Attack& attack, int target, Vec2 point); std::vector chain_get(Sim& sim, Fighter& fighter); void tesla_update(Sim& sim); Attack* tether_get(Fighter& fighter, int key, int hand); void hook_attach(Sim& sim, Attack& attack); - void piece_launch(Sim& sim, Attack& piece); + void instant_launch(Sim& sim, Attack& piece); + void whip_pull(Sim& sim, Attack& piece); void attack_explode(Sim& sim, Attack& attack); - void flail_move(Sim& sim, Attack& attack); + void vacuum_move(Sim& sim, Attack& attack); + void gust_move(Sim& sim, Attack& attack); + void crawl_move(Sim& sim, Attack& attack); + void body_bounce(Sim& sim, Attack& attack); + void shots_drift(Sim& sim, Attack& source, float blow, float pull, float restore); void orbits_update(Sim& sim); void attacks_cap(Sim& sim); }; diff --git a/src/world.cpp b/src/world.cpp index 981f14e..7a3f99c 100644 --- a/src/world.cpp +++ b/src/world.cpp @@ -396,6 +396,7 @@ void World::update(Sim& sim) 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; + dummy.donkWindow -= TICK; } } diff --git a/src/world.h b/src/world.h index 03a53af..f0aad9a 100644 --- a/src/world.h +++ b/src/world.h @@ -70,9 +70,12 @@ struct Dummy float flash = 0; float health = 0; float regrow = 0; + float donkWindow = 0; float shockCooldown = 0; float wobble = 0; float wobbleSpeed = 0; + int donkOwner = -1; + int donkWeapon = -1; int tile = -1; }; diff --git a/tools/assets_generate.py b/tools/assets_generate.py deleted file mode 100644 index d6df0c2..0000000 --- a/tools/assets_generate.py +++ /dev/null @@ -1,777 +0,0 @@ -# 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 deleted file mode 100644 index f0ae8fb..0000000 --- a/tools/balance_runs.py +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100755 index 866aa3b..0000000 --- a/tools/determinism_check.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/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 deleted file mode 100644 index 3b7a9d5..0000000 --- a/tools/fonts_export.py +++ /dev/null @@ -1,189 +0,0 @@ -# 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 -# Characters past ASCII, each after the last in its own cell (written into the .fnt as UTF-8). -EXTRA = "°←↑→↓" -# Small glyphs drawn here instead of taken from the TTF: the degree sign a pixel wider than its own, and arrows (the -# TTF has none), the up one turned for the rest. -ARROW_UP = ["..##..", ".####.", "######", "..##..", "..##..", "..##.."] -ARROW_RIGHT = ["".join(ARROW_UP[len(ARROW_UP) - 1 - y][x] for y in range(len(ARROW_UP))) for x in range(len(ARROW_UP[0]))] -SMALL_DRAWN = { - "°": {"rows": [".##.", "#..#", "#..#", "#..#", ".##."], "left": 0, "top": 2, "advance": 5}, - "←": {"rows": [row[::-1] for row in ARROW_RIGHT], "left": 0, "top": 5, "advance": 7}, - "↑": {"rows": ARROW_UP, "left": 0, "top": 5, "advance": 7}, - "→": {"rows": ARROW_RIGHT, "left": 0, "top": 5, "advance": 7}, - "↓": {"rows": ARROW_UP[::-1], "left": 0, "top": 5, "advance": 7}, -} -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)] + list(EXTRA) - - -# 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 = dict(SMALL_DRAWN) - for character in characters(): - if character in glyphs: - continue - # 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 = (len(glyphs) + 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, ensure_ascii=False)}: {json.dumps(entry)}{"," if i < len(entries) - 1 else ""}' - for i, (character, entry) in enumerate(entries.items())] - lines += [" }", "}"] - (FONTS / f"{name}.fnt").write_text("\n".join(lines) + "\n", encoding="utf-8") - 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/sfx_build.py b/tools/sfx_build.py deleted file mode 100644 index 1533ae8..0000000 --- a/tools/sfx_build.py +++ /dev/null @@ -1,367 +0,0 @@ -# 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}, - "splat": {"f": "4001_32_1", "n": 0.5}, - "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 deleted file mode 100644 index ec2ffdf..0000000 --- a/tools/sfx_library_demo.py +++ /dev/null @@ -1,260 +0,0 @@ -# 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 deleted file mode 100644 index e9a1775..0000000 --- a/tools/sfx_loudness.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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, - "splat": 0.35, - "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 deleted file mode 100644 index 2f981c7..0000000 --- a/tools/sfx_review.py +++ /dev/null @@ -1,779 +0,0 @@ -# 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 deleted file mode 100644 index 6bb229d..0000000 --- a/tools/sfx_review_notes.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "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 deleted file mode 100644 index 3871a0b..0000000 --- a/tools/sfx_review_round1.json +++ /dev/null @@ -1,476 +0,0 @@ -{ - "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 deleted file mode 100644 index 70111d6..0000000 --- a/tools/sfx_review_round2.json +++ /dev/null @@ -1,541 +0,0 @@ -{ - "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 deleted file mode 100644 index d1fac8e..0000000 --- a/tools/sfx_suites.py +++ /dev/null @@ -1,214 +0,0 @@ -# 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/node_pre.js b/web/node_pre.js similarity index 100% rename from tools/node_pre.js rename to web/node_pre.js diff --git a/tools/web_pre.js b/web/web_pre.js similarity index 100% rename from tools/web_pre.js rename to web/web_pre.js diff --git a/tools/web_shell.html b/web/web_shell.html similarity index 100% rename from tools/web_shell.html rename to web/web_shell.html