diff --git a/resources/gfx/weapons.anm2 b/resources/gfx/weapons.anm2 index a94bab4..5890082 100644 --- a/resources/gfx/weapons.anm2 +++ b/resources/gfx/weapons.anm2 @@ -46,6 +46,9 @@ "layers": [ {"name": "sprite"} ], + "nulls": [ + {"name": "Muzzle"} + ], "defaultAnimation": "Fist", "animations": { "Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, @@ -62,12 +65,12 @@ "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}]}]}, + "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 20}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -3.5]}]}]}, + "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}], "nulls": [{"null": 0, "frames": [{"pos": [14, -2.5]}]}]}, + "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 22}]}], "nulls": [{"null": 0, "frames": [{"pos": [23, -1.5]}]}]}, + "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}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, 0]}]}]}, + "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 29}]}], "nulls": [{"null": 0, "frames": [{"pos": [21, 0]}]}]}, + "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -0.5]}]}]}, "Mine": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, "Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, diff --git a/resources/sfx/SOURCES.md b/resources/sfx/SOURCES.md index dc7f5ed..7eab8e4 100644 --- a/resources/sfx/SOURCES.md +++ b/resources/sfx/SOURCES.md @@ -5,7 +5,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | Sound | Source | From | Speed | Rate | BRR bytes | |---|---|---|---|---|---| | beam | SpaceZap 4001_88_7 | 1.09s | 1x | 11025 | 2727 | -| beep | synth beeps | | 1x | 11025 | 315 | +| beep | synth beeps | | 1x | 11025 | 360 | | blast | synth pew | | 1x | 11025 | 1053 | | bolter | US03-11-1 ELECTRICITY - SPARK GAP CRACKLE AND HUM | 7.90s | 1x | 11025 | 1791 | | bolter_heavy | US03-11-1 ELECTRICITY - SPARK GAP CRACKLE AND HUM | 7.90s | 0.7x | 11025 | 2556 | @@ -18,6 +18,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | cast | synth ember | | 1x | 11025 | 2556 | | charge | synth warp | | 1x | 11025 | 5076 | | chat | synth beeps | | 1x | 11025 | 576 | +| countdown | US03-58-2 SPACE, DOOR - DOOR LOCK BEEP | 0.16s | 1x | 11025 | 1557 | | crawl | kept from the synth set | | | | | | dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 | | death | US03-34-1 KICK, HUMAN - SINGLE KICK INTO STOMACH | 0.17s | 1x | 11025 | 2916 | @@ -117,4 +118,4 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | whip_heavy | kept from the demo1 (its whip) set | | | | | | wisp | US02-45-5 ARCHERY - SHOOT FLAMING ARROW | 0.18s | 1x | 11025 | 1584 | -The built sounds come to 171 KB as BRR. +The built sounds come to 172 KB as BRR. diff --git a/resources/sfx/countdown.ogg b/resources/sfx/countdown.ogg new file mode 100644 index 0000000..d8d1fbf Binary files /dev/null and b/resources/sfx/countdown.ogg differ diff --git a/src/audio.cpp b/src/audio.cpp index bac947f..484cecd 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -23,6 +23,7 @@ constexpr const char* SOUND_NAMES[SOUND_COUNT] = { "cast", "charge", "chat", + "countdown", "crawl", "dash", "death", diff --git a/src/audio.h b/src/audio.h index 1db2b86..48a53e0 100644 --- a/src/audio.h +++ b/src/audio.h @@ -24,6 +24,7 @@ enum SoundId SOUND_CAST, SOUND_CHARGE, SOUND_CHAT, + SOUND_COUNTDOWN, SOUND_CRAWL, SOUND_DASH, SOUND_DEATH, diff --git a/src/chat.cpp b/src/chat.cpp index 41109f2..8f25aa6 100644 --- a/src/chat.cpp +++ b/src/chat.cpp @@ -136,6 +136,10 @@ void Chat::say(Game& game, int member, const std::string& text) return; times.push_back(now); + // With Chat off, others' messages go unseen and unheard. + typing[member] = false; + if (!game.settings.isChatShown && member != game.net.selfId) + return; bool isOnline = game.session.isActive; int fighter = isOnline ? game.session.fighter_get(game, member) : game.local; Color color = fighter >= 0 ? game.color_get(fighter, TINT_PLAIN) : WHITE; @@ -144,7 +148,6 @@ void Chat::say(Game& game, int member, const std::string& text) if ((int)lines.size() > CHAT_LINES_MAX) lines.erase(lines.begin()); bubbles[member].push_back({.text = text, .time = now}); - typing[member] = false; game.audio.play(SOUND_CHAT); } diff --git a/src/effects.cpp b/src/effects.cpp index 6afbfbb..68d93bd 100644 --- a/src/effects.cpp +++ b/src/effects.cpp @@ -116,9 +116,10 @@ void Effects::popup_spawn(Vec2 position, const char* text, Color color) effect_add(popups, popup); } -void Effects::flash_spawn(Vec2 position, float angle, float size, Color color) +void Effects::flash_spawn(Flash flash) { - effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size}); + flash.life = FLASH_LIFE; + effect_add(flashes, flash); } // A particle at position, shrinking and fading as its life runs out: an outline in its colour around a core shaded by @@ -207,6 +208,7 @@ void Effects::draw(Game& game) // Each flash is a long streak along the shot crossed by a short one, shrinking as it fades. for (Flash& flash : flashes) { + game.scene.flash_follow(flash); float fade = flash.life / FLASH_LIFE; float length = FLASH_LENGTH * flash.size * fade; float width = FLASH_WIDTH * flash.size * fade; diff --git a/src/effects.h b/src/effects.h index a7c1f6c..9ba1e33 100644 --- a/src/effects.h +++ b/src/effects.h @@ -49,6 +49,10 @@ struct Flash float angle = 0; float life = 0; float size = 0; + // The fighter, hand and weapon that fired it, for keeping it on the muzzle (see Scene::flash_follow). + int owner = 0; + int hand = 0; + int weapon = 0; }; // A fading copy of a sprite left behind by something moving fast, and what its fighter wore (nothing, FASHION_NONE, @@ -66,7 +70,7 @@ class Effects public: void burst(Burst burst, Vec2 position, float angle, Color color); void afterimage_spawn(Pose pose, int worn); - void flash_spawn(Vec2 position, float angle, float size, Color color); + void flash_spawn(Flash flash); void afterimage_draw(Game& game); void number_spawn(Vec2 position, int value, int target, Color color); void dps_record(int amount); diff --git a/src/fighter.cpp b/src/fighter.cpp index a96a921..199a177 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -145,7 +145,15 @@ void Fighter::damage_take(Sim& sim, Damage damage) knock_take(direction * knock, damage.owner); if (health > 0) return; + die(sim); + if (damage.owner != index) + sim.fighters[damage.owner].kills++; +} +// Out of the round in a burst, its health gone and its attacks vanishing with it. +void Fighter::die(Sim& sim) +{ + health = 0; isAlive = false; charge_cancel(sim); for (Attack& attack : sim.weapons.attacks) diff --git a/src/fighter.h b/src/fighter.h index 87581c6..30f71e4 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -123,6 +123,11 @@ public: float scale = 1; float shockCooldown = 0; float contactCooldown = 0; + // Running totals for the profile's stats (see Stats): damage dealt, deathmatch kills and matches won. They're + // never reset while the fighter lasts, so rewinding the game corrects them. + float damageDealt = 0; + int kills = 0; + int wins = 0; int index = 0; int launcher = 0; int slot = 0; @@ -152,6 +157,7 @@ public: void position_set(Sim& sim, Vec2 feet); void slots_set(int weapons, int trinkets); void damage_take(Sim& sim, Damage damage); + void die(Sim& sim); void update(Sim& sim); void blend(float alpha); void impulse_add(Vec2 impulse); diff --git a/src/game.h b/src/game.h index aaf0da8..f0ca785 100644 --- a/src/game.h +++ b/src/game.h @@ -14,6 +14,7 @@ #include "settings.h" #include "sim.h" #include "sprites.h" +#include "stats.h" #include "text.h" #include "ui.h" @@ -48,6 +49,7 @@ struct Game Session session; Sim sim; Sprites sprites; + Stats stats; Text text; Ui ui; Vec2 camera; diff --git a/src/input.cpp b/src/input.cpp index ce60a6d..d493fb6 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -11,7 +11,6 @@ constexpr int BIND_WHEEL_DOWN = BIND_WHEEL_UP + 1; constexpr int BIND_AXIS = 100; constexpr float AXIS_MAX = 32767; constexpr float AXIS_THRESHOLD = 0.5f; -constexpr float AIM_DEADZONE = 0.3f; constexpr float AIM_REACH = 120; constexpr float NAV_THRESHOLD = 0.6f; constexpr Uint64 NAV_DELAY = 400; @@ -308,7 +307,7 @@ void Input::poll(Game& game) if (!device.pad) continue; Vec2 axes = {device.axes[SDL_GAMEPAD_AXIS_RIGHTX], device.axes[SDL_GAMEPAD_AXIS_RIGHTY]}; - if (vec2_length(axes) <= AIM_DEADZONE) + if (vec2_length(axes) <= aimDeadzone) continue; Device& aimer = device.isClaimed ? device : devices[DEVICE_KEYBOARD]; aimer.stick = vec2_normalize(axes); @@ -487,6 +486,19 @@ bool Input::is_pad_binding_down(Device& device, int binding) return (binding - BIND_AXIS) % 2 ? value > AXIS_THRESHOLD : value < -AXIS_THRESHOLD; } +// Rumbles the gamepad playing a device, at a strength from 0 to 1, both motors alike. The keyboard's device rumbles +// whichever gamepads aim for it (those not claimed by a player of their own). +void Input::rumble_play(int device, float strength, Uint32 milliseconds) +{ + Uint16 level = std::clamp(strength, 0.0f, 1.0f) * SDL_MAX_UINT16; + for (int i = 0; i < DEVICE_COUNT; i++) + { + bool isPlaying = i == device || (device == DEVICE_KEYBOARD && !devices[i].isClaimed); + if (devices[i].pad && isPlaying) + SDL_RumbleGamepad(devices[i].pad, level, level, milliseconds); + } +} + // 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) diff --git a/src/input.h b/src/input.h index f3b840e..d19c6d4 100644 --- a/src/input.h +++ b/src/input.h @@ -149,6 +149,8 @@ class Input public: Binding bindings[CONTROL_COUNT]; Device devices[DEVICE_COUNT]; + // How far a right stick must lean before it aims (see Settings::stickDeadzone). + float aimDeadzone = 0.3f; Vec2 mouse; char typed[TYPED_LENGTH] = {}; float scroll = 0; @@ -190,6 +192,7 @@ public: bool is_mouse_released(MouseButton button); void binding_name_get(int binding, BindingSide side, char* text, int size); void control_name_get(Control control, char* text, int size); + void rumble_play(int device, float strength, Uint32 milliseconds); bool is_control_down(int device, Control control); diff --git a/src/main.cpp b/src/main.cpp index f93f3a2..3e3889d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,8 @@ #include #include +// How early a browser frame may come and still go under the frame rate limit. +constexpr Uint64 FRAME_SLACK = 2 * SDL_NS_PER_MS; constexpr int WINDOW_SCALE = 2; constexpr double MAX_FRAME_TIME = 0.25; #ifdef SDL_PLATFORM_EMSCRIPTEN @@ -69,6 +71,7 @@ void game_tick(Game& game) static void presentation_update(Game& game) { game.effects.update(); + game.stats.update(game); game.ui.update(game); game.scene.update(); game.cameraPrevious = game.camera; @@ -122,6 +125,8 @@ struct FrameClock static Game game; static FrameClock frameClock; +// When the next frame is due under the frame rate limit (see frame_wait_get), in nanoseconds. +static Uint64 frameDue = 0; // One frame's input and as many fixed ticks as the time since the last frame allows. static void frame_step() @@ -169,6 +174,28 @@ static void frame_step() game.alpha = frameClock.accumulator / TICK; } +// The frame rate limit in use: none while vsync paces the frames. +static int frame_limit_get() +{ + return game.settings.isVsync ? 0 : game.settings.fpsLimit; +} + +// How long until the frame rate limit lets the next frame go, in nanoseconds. +static Uint64 frame_wait_get() +{ + Uint64 now = SDL_GetTicksNS(); + return frame_limit_get() > 0 && frameDue > now ? frameDue - now : 0; +} + +// Sets when the frame after this one is due under the limit: a limit's worth of time after this one was due, or after +// now if it ran late, so a slow frame doesn't bring on a burst to catch up. +static void frame_due_advance() +{ + int limit = frame_limit_get(); + if (limit > 0) + frameDue = std::max(frameDue, SDL_GetTicksNS()) + SDL_NS_PER_SECOND / limit; +} + // One frame, stepped and drawn. Returns whether to keep going. static bool frame_run() { @@ -178,8 +205,13 @@ static bool frame_run() } #ifdef SDL_PLATFORM_EMSCRIPTEN +// The browser paces the frames itself, so under a frame rate limit those that come too soon are skipped (one that's +// only FRAME_SLACK early still goes, for screens refreshing a shade fast). static void frame_callback() { + if (frame_wait_get() > FRAME_SLACK) + return; + frame_due_advance(); frame_run(); } @@ -276,9 +308,12 @@ int main(int argc, char** argv) }); return 0; #endif - while (frame_run()) + do { + SDL_DelayPrecise(frame_wait_get()); + frame_due_advance(); } + while (frame_run()); game.settings.save(game); game.net.shutdown(); diff --git a/src/match.cpp b/src/match.cpp index c88e85a..6fc0be7 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -187,6 +187,7 @@ void Match::update(Sim& sim) if (winner >= 0 && wins[winner] >= rules.wins_get()) { state = MATCH_OVER; + sim.fighters[winner].wins++; std::fill(std::begin(isReady), std::end(isReady), false); break; } @@ -494,7 +495,7 @@ void Match::fight_start(Sim& sim) } // The round ends with one fighter (or none) left standing, or when time runs out with whoever has the most health, -// drawn if that's shared. +// drawn if that's shared. Everyone else standing when time runs out dies on the spot. void Match::deathmatch_update(Sim& sim) { int alive = 0; @@ -519,6 +520,9 @@ void Match::deathmatch_update(Sim& sim) winner = fighter.health == best ? -1 : i; best = fighter.health; } + for (int i = 0; i < sim.fighterCount; i++) + if (sim.fighters[i].isAlive && sim.fighters[i].health < best) + sim.fighters[i].die(sim); } if (winner >= 0) wins[winner]++; diff --git a/src/menu.cpp b/src/menu.cpp index a6fb86b..b356bd0 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -24,7 +24,9 @@ constexpr float DIM_ALPHA = 0.6f; constexpr float DIVIDER = 2; constexpr float STICK_DEADZONE = 0.3f; constexpr float STICK_SCROLL = 240; -constexpr float HEADER_BUTTON_WIDTH = 92; +// Each header button's longest label, which sizes it so it keeps its width whichever label shows. +constexpr const char* BUTTON_WIDEST[HEADER_BUTTON_COUNT] = {"Ready!", "Are you sure?", "Stop Spectating", + "Are you sure?"}; constexpr float LABEL_WIDTH = 136; constexpr float NUMBER_WIDTH = 84; constexpr float PADDING = 8; @@ -52,11 +54,6 @@ constexpr const char* PICK_NAMES[] = {"Random", "Choice"}; constexpr const char* TIMING_NAMES[] = {"First Round", "Every Round", "Period"}; // The header's tab strip: the whole header, less room on the right for the match's buttons. -static Rect tab_area_get(Game& game, Rect header) -{ - bool isMatch = game.sim.match.state != MATCH_PRACTICE; - return {header.x, header.y, header.w - (isMatch ? HEADER_BUTTON_WIDTH * 2 : 0), header.h}; -} // The Menu control opens and closes it. While open: click a tab to switch to it. On an item tab, click an item to // select it, and click it again to put it in the target slot (then the target moves on to the next empty slot); click @@ -108,6 +105,7 @@ void Menu::update(Game& game) } } isForfeitArmed &= isOpen; + isQuitArmed &= isOpen; int tabs[TAB_COUNT]; int shown = tabs_get(game, tabs); if (std::find(tabs, tabs + shown, tab) == tabs + shown) @@ -120,13 +118,14 @@ void Menu::update(Game& game) Vec2 mouse = input.mouse; bool isLeft = input.is_mouse_pressed(MOUSE_LEFT); bool isRight = input.is_mouse_pressed(MOUSE_RIGHT); - Rect strip = tab_area_get(game, layout.header); - if (isLeft && is_point_in_rect(mouse, strip)) + HeaderLayout header = header_layout_get(game, layout.header); + for (int i = 0; i < header.tabCount && isLeft; i++) { - int clicked = tabs[std::min(shown - 1, (int)((mouse.x - strip.x) / strip.w * shown))]; - if (clicked != tab) + if (!is_point_in_rect(mouse, header.tabCells[i])) + continue; + if (header.tabs[i] != tab) game.audio.play(SOUND_SELECT); - tab = clicked; + tab = header.tabs[i]; } if (tab >= ITEM_TABS) return; @@ -446,75 +445,125 @@ void Menu::about_draw(Game& game, Rect area) } } -// 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. Spectating, it's a button to stop. +// Lays the header out: the tabs shown, then in a match its buttons (Ready while a round counts down, and Forfeit, or +// one to stop spectating), and on desktop Quit last. Each cell is as wide as its name (a button's longest label) and +// padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab names are big +// when they all fit. +HeaderLayout Menu::header_layout_get(Game& game, Rect area) +{ + Text& text = game.text; + Match& match = game.sim.match; + HeaderLayout header; + header.tabCount = tabs_get(game, header.tabs); + if (game.session.is_watching(game)) + header.buttons[header.buttonCount++] = BUTTON_STOP; + else if (match.state != MATCH_PRACTICE) + { + if (match.state == MATCH_COUNTDOWN) + header.buttons[header.buttonCount++] = BUTTON_READY; + header.buttons[header.buttonCount++] = BUTTON_FORFEIT; + } +#ifndef __EMSCRIPTEN__ + header.buttons[header.buttonCount++] = BUTTON_QUIT; +#endif + + float buttons = 0; + for (int i = 0; i < header.buttonCount; i++) + buttons += text.size_get(BUTTON_WIDEST[header.buttons[i]]).x + PADDING * 2; + auto tabs_measure = [&](float scale) { + float width = 0; + for (int i = 0; i < header.tabCount; i++) + width += text.size_get(TAB_NAMES[header.tabs[i]], FONT_SMALL, scale).x + PADDING * 2; + return width; + }; + header.scale = tabs_measure(TAB_SCALE) + buttons <= area.w ? TAB_SCALE : 1; + float spare = (area.w - tabs_measure(header.scale) - buttons) / (header.tabCount + header.buttonCount); + float x = area.x; + auto cell_next = [&](float width) { + Rect cell = {std::round(x), area.y, std::round(x + width + PADDING * 2 + spare) - std::round(x), area.h}; + x += width + PADDING * 2 + spare; + return cell; + }; + for (int i = 0; i < header.tabCount; i++) + header.tabCells[i] = cell_next(text.size_get(TAB_NAMES[header.tabs[i]], FONT_SMALL, header.scale).x); + for (int i = 0; i < header.buttonCount; i++) + header.buttonCells[i] = cell_next(text.size_get(BUTTON_WIDEST[header.buttons[i]]).x); + return header; +} + +// The header's tabs, divided, and any buttons (see header_layout_get). Forfeit and Quit ask again before going. void Menu::header_draw(Game& game, MenuLayout& layout) { Renderer& renderer = game.renderer; Text& text = game.text; Ui& ui = game.ui; - Rect strip = tab_area_get(game, layout.header); - int tabs[TAB_COUNT]; - int shown = tabs_get(game, tabs); - float width = 0; - for (int i = 0; i < shown; i++) - width += text.size_get(TAB_NAMES[tabs[i]], FONT_SMALL, TAB_SCALE).x + PADDING * 2; - float scale = width <= strip.w ? TAB_SCALE : 1; - for (int i = 0; i < shown; i++) + HeaderLayout header = header_layout_get(game, layout.header); + float scale = header.scale; + for (int i = 0; i < header.tabCount; i++) { // Another tab under the mouse gets a faint box behind its name. - Rect cell = {std::round(strip.x + strip.w * i / shown), strip.y, std::round(strip.w / shown), strip.h}; - bool isHovered = tabs[i] != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); + Rect cell = header.tabCells[i]; + int shown = header.tabs[i]; + bool isHovered = shown != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); if (isHovered) renderer.rect_draw({cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}, color_fade(ui.shade, TAB_HOVER_ALPHA)); - const char* name = TAB_NAMES[tabs[i]]; - Vec2 size = text.size_get(name, FONT_SMALL, scale); - float center = strip.x + strip.w * (i * 2 + 1) / (shown * 2); - Vec2 corner = {std::round(center - size.x / 2), std::round(strip.y + (strip.h - size.y) / 2)}; + Vec2 size = text.size_get(TAB_NAMES[shown], FONT_SMALL, scale); + Vec2 corner = {std::round(cell.x + (cell.w - size.x) / 2), std::round(cell.y + (cell.h - size.y) / 2)}; text.draw( - renderer, name, - {.color = tabs[i] == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); + renderer, TAB_NAMES[shown], + {.color = shown == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); if (i > 0) - renderer.rect_draw({std::round(strip.x + strip.w * i / shown - DIVIDER / 2), strip.y + PADDING, DIVIDER, - strip.h - PADDING * 2}, - ui.tint); + renderer.rect_draw({cell.x - DIVIDER / 2, cell.y + PADDING, DIVIDER, cell.h - PADDING * 2}, ui.tint); } Match& match = game.sim.match; - if (match.state == MATCH_PRACTICE) - return; - Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING}; - if (game.session.is_watching(game)) + for (int i = 0; i < header.buttonCount; i++) { - button.w += HEADER_BUTTON_WIDTH; - if (ui.button_draw(game, button, "Stop Spectating")) + Rect cell = header.buttonCells[i]; + Rect button = {cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}; + switch (header.buttons[i]) { - game.session.watch_send(game, 0); - isOpen = false; + case BUTTON_STOP: + if (!ui.button_draw(game, button, "Stop Spectating")) + break; + game.session.watch_send(game, 0); + isOpen = false; + break; + case BUTTON_READY: + { + bool isReady = match.isReady[game.local]; + ui.widgets_enable(!isReady); + game.input.devices[game.devices[game.local]].isReady |= + ui.button_draw(game, button, isReady ? "Ready!" : "Ready", &match.isReady[game.local]); + ui.widgets_enable(true); + break; + } + case BUTTON_FORFEIT: + { + const char* forfeit = match.state == MATCH_OVER ? "Leave" : "Forfeit"; + if (!ui.button_draw(game, button, isForfeitArmed ? "Are you sure?" : forfeit, &isForfeitArmed)) + break; + if (isForfeitArmed && game.session.is_playing_arena(game)) + { + game.session.arena_leave(game); + isOpen = false; + } + else if (isForfeitArmed) + match.sandbox_return(game.sim); + isForfeitArmed = !isForfeitArmed; + break; + } + case BUTTON_QUIT: + if (!ui.button_draw(game, button, isQuitArmed ? "Are you sure?" : "Quit", &isQuitArmed)) + break; + game.input.isQuit |= isQuitArmed; + isQuitArmed = !isQuitArmed; + break; + default: + break; } - return; } - if (match.state == MATCH_COUNTDOWN) - { - bool isReady = match.isReady[game.local]; - ui.widgets_enable(!isReady); - game.input.devices[game.devices[game.local]].isReady |= - ui.button_draw(game, button, isReady ? "Ready!" : "Ready", &match.isReady[game.local]); - ui.widgets_enable(true); - } - button.x += HEADER_BUTTON_WIDTH; - const char* forfeit = match.state == MATCH_OVER ? "Leave" : "Forfeit"; - if (!ui.button_draw(game, button, isForfeitArmed ? "Are you sure?" : forfeit, &isForfeitArmed)) - return; - if (isForfeitArmed && game.session.is_playing_arena(game)) - { - game.session.arena_leave(game); - isOpen = false; - } - else if (isForfeitArmed) - match.sandbox_return(game.sim); - isForfeitArmed = !isForfeitArmed; } // An item tab: the item grid with its scroll bar, the selection's description (scrolling, back to its top for each @@ -782,12 +831,10 @@ void Menu::online_draw(Game& game, Rect area) else if (!net.error.empty()) status_draw(net.error.c_str(), true); - // Where the lobbies are hosted. + // The online settings, as in Settings. 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}); + game.settings.online_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, LABEL_WIDTH); return; } @@ -855,7 +902,8 @@ void Menu::online_draw(Game& game, Rect area) 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 = rowWidth - MINI_PLAYER_WIDTH - (member.ping >= 0 ? pingWidth : 0); + bool isPinged = member.ping >= 0 && game.settings.isPingShown; + float room = rowWidth - MINI_PLAYER_WIDTH - (isPinged ? pingWidth : 0); SDL_snprintf(line, sizeof(line), "- %s%s", name.c_str(), tags); while (text.size_get(line).x > room && !name.empty()) { @@ -865,7 +913,7 @@ void Menu::online_draw(Game& game, Rect area) 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) + if (isPinged) 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); } @@ -1227,7 +1275,7 @@ bool Menu::is_bars_open() // Fills tabs with the ones shown, in order, returning how many: every tab in the sandbox, and in a match the item tabs // (to choose from when its countdown lets fighters, and otherwise to look at and level the sandbox's), Play (the -// game's rules) and Settings. +// game's rules) and Settings. Profile and About are the sandbox's alone. int Menu::tabs_get(Game& game, int* tabs) { Match& match = game.sim.match; @@ -1238,7 +1286,7 @@ int Menu::tabs_get(Game& game, int* tabs) tabs[count++] = i; return count; } - for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS, TAB_ABOUT}) + for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS}) tabs[count++] = tab; return count; } @@ -1253,33 +1301,42 @@ bool Menu::is_choosable(Game& game, ItemKind kind) 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(ItemKind kind, int index, int count) +// The size a hotbar of count slots takes in the HUD: the largest that keeps it within its column. +static IconSize hud_size_get(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, - kind == ITEM_TRINKET ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN}; - if (is_bars_open()) - 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}; -} - -// The size a hotbar of count slots takes: full in the menu, and in the HUD the largest that keeps it within its column. -IconSize Menu::slot_size_get(int count) -{ - if (is_bars_open()) - return ICON_FULL; for (int size = ICON_FULL; size < ICON_TINY; size++) if (count * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size] <= HUD_COLUMN_WIDTH) return (IconSize)size; return ICON_TINY; } +// Where a hotbar slot sits in the HUD: the weapon bar top left and the trinket bar bottom left, each as large as fits +// its column. +Rect Menu::slot_hud_rect(ItemKind kind, int index, int count) +{ + float slot = SLOT_SIZES[hud_size_get(count)]; + float step = slot + SLOT_GAPS[hud_size_get(count)]; + float y = kind == ITEM_TRINKET ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN; + return {std::round(HOTBAR_MARGIN + index * step), std::round(y), slot, slot}; +} + +// Where a hotbar slot sits now: in the HUD, or on an item tab under the menu either side of its middle, full size. +Rect Menu::slot_rect(ItemKind kind, int index, int count) +{ + if (!is_bars_open()) + return slot_hud_rect(kind, index, count); + float step = SLOT_SIZES[ICON_FULL] + SLOT_GAPS[ICON_FULL]; + float x = kind == ITEM_TRINKET ? CANVAS_WIDTH / 2.0f + SLOT_GAP + index * step + : CANVAS_WIDTH / 2.0f - SLOT_GAP - (count - index) * step + SLOT_GAP; + return {std::round(x), BAR_Y, SLOT_SIZES[ICON_FULL], SLOT_SIZES[ICON_FULL]}; +} + +// The size a hotbar of count slots takes: full in the menu, and in the HUD as hud_size_get says. +IconSize Menu::slot_size_get(int count) +{ + return is_bars_open() ? ICON_FULL : hud_size_get(count); +} + // Lays the panel out: the header across the top, and below it, right to left, the description, divider, scroll bar // and as many grid columns as fit. MenuLayout Menu::layout_get() diff --git a/src/menu.h b/src/menu.h index 590d2a7..d7458b3 100644 --- a/src/menu.h +++ b/src/menu.h @@ -53,6 +53,28 @@ struct MenuLayout int rows = 1; }; +// A button in the menu's header during a match. +enum HeaderButton +{ + BUTTON_READY, + BUTTON_FORFEIT, + BUTTON_STOP, + BUTTON_QUIT, + HEADER_BUTTON_COUNT +}; + +// The header's cells (see Menu::header_layout_get): the tabs shown, then any buttons, and the scale the tab names take. +struct HeaderLayout +{ + Rect buttonCells[HEADER_BUTTON_COUNT]; + Rect tabCells[TAB_COUNT]; + float scale = 1; + int buttonCount = 0; + int buttons[HEADER_BUTTON_COUNT]; + int tabCount = 0; + int tabs[TAB_COUNT]; +}; + // 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 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 @@ -76,6 +98,7 @@ public: void target_fill(Game& game); bool is_bars_open(); Rect slot_rect(ItemKind kind, int index, int count); + Rect slot_hud_rect(ItemKind kind, int index, int count); IconSize slot_size_get(int count); bool is_choosable(Game& game, ItemKind kind); int target_get(Game& game, ItemKind kind); @@ -101,6 +124,7 @@ private: int selections[ITEM_TABS] = {}; int page = PAGE_MAIN; bool isForfeitArmed = false; + bool isQuitArmed = false; bool isMultiplayer = false; // The rules a game queued in a lobby is hosted with (kept apart from this screen's own single player ones). Rules queueRules = rules_get(MODE_DEATHMATCH); @@ -113,6 +137,7 @@ private: MenuLayout layout_get(); Rect box_rect(int index); int tabs_get(Game& game, int* tabs); + HeaderLayout header_layout_get(Game& game, Rect header); int item_count(); int row_count(); void slot_assign(Game& game, int slot); diff --git a/src/protocol.h b/src/protocol.h index 0cc4209..653e4bc 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 5; +constexpr int PROTOCOL_VERSION = 6; 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. diff --git a/src/scene.cpp b/src/scene.cpp index 58683a0..5f6a803 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -6,6 +6,11 @@ #include #include +// Rumble: the damage taken that shakes a gamepad fully, and how long, and the lighter tick for landing a hit. +constexpr float RUMBLE_FULL_DAMAGE = 60; +constexpr Uint32 RUMBLE_HIT_TIME = 180; +constexpr float RUMBLE_LANDED = 0.15f; +constexpr Uint32 RUMBLE_LANDED_TIME = 60; constexpr int BLINK_MILLISECONDS = 80; // How long a claimed shot takes to turn from its old owner's colour to its new one's. @@ -97,6 +102,7 @@ constexpr float TELEPORT_RING_ALPHA = 0.35f; constexpr float WHIP_INNER = 2; constexpr float WHIP_JITTER = 0.5f; constexpr float WHIP_OUTER = 5; +constexpr float WHIP_TAPER = 0.35f; constexpr float WISP_FRAME_STAGGER = 0.3f; constexpr int TOMATO_BLOBS = 6; constexpr float TOMATO_BLOB_MAX = 3.5f; @@ -167,6 +173,8 @@ struct Stroke float outer = 0; float inner = 0; float jitter = 0; + // Its width at the end, as a share of its width at the start. + float taper = 1; }; // A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Jitter @@ -205,10 +213,12 @@ 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] / reach); + float width = math_lerp(1, stroke.taper, distances[i] / distance); if (layer == 0) - game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); + game.renderer.line_draw(points[i], points[i + 1], stroke.outer * width, color_dim(color, strength)); else - game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength))); + game.renderer.line_draw(points[i], points[i + 1], stroke.inner * width, + color_shade(color, fill_get(strength))); } } } @@ -442,10 +452,18 @@ void Scene::shake_add(float amount) game.effects.shake_add(amount); } -void Scene::flash_spawn(Vec2 position, float angle, float size, int owner) +// A shot's muzzle flash, which keeps to the muzzle of the weapon that fired it (see flash_follow). +void Scene::flash_spawn(Attack& attack, float size) { - if (!isMuted) - game.effects.flash_spawn(position, angle, size, game.color_get(owner)); + if (isMuted) + return; + game.effects.flash_spawn({.color = game.color_get(attack.owner), + .position = muzzle_get(attack), + .angle = attack.angle, + .size = size, + .owner = attack.owner, + .hand = attack.hand, + .weapon = attack.weapon}); } void Scene::fighter_afterimage_spawn(Fighter& fighter) @@ -477,12 +495,22 @@ void Scene::attack_aura_spawn(Attack& attack) tint_get(game, attack)); } +// A hit's number (unless they're turned off) and its share of the DPS meter, and with rumble on, a shake of this +// screen's gamepad: harder for more damage taken, and a light tick for a hit landed. void Scene::number_spawn(Damage& damage) { if (isMuted) return; - game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); + if (game.settings.isDamageShown) + game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); game.effects.dps_record(damage.amount); + if (!game.settings.isRumbling) + return; + int device = game.devices[game.local]; + if (damage.target == game.local) + game.input.rumble_play(device, damage.amount / RUMBLE_FULL_DAMAGE, RUMBLE_HIT_TIME); + else if (damage.owner == game.local) + game.input.rumble_play(device, RUMBLE_LANDED, RUMBLE_LANDED_TIME); } // A level going up or down chimes, and outside the menu pops up over this screen's fighter. @@ -880,17 +908,18 @@ void Scene::attacks_draw() } else if (kind == ATTACK_WHIP) { - // A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite - // rides the end of the whip, pointing the way it runs. + // A plain lash keeps its full width while it unfurls, then thins away after the crack, slowly at first. It + // tapers toward its tip. 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.is_charged() ? progress : std::max(0.0f, (progress - PATH_OUT) / (1 - PATH_OUT)); - float width = (1 - fade) * attack.scale; + float width = (1 - (attack.is_charged() ? fade : fade * fade)) * attack.scale; std::vector points = 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.is_charged() ? 0 : WHIP_JITTER}); + {points, WHIP_OUTER * width, WHIP_INNER * width, attack.is_charged() ? 0 : WHIP_JITTER, + attack.is_charged() ? 1 : WHIP_TAPER}); game.sprites.effects.draw(renderer, "Whip Tip", 0, {.color = color_fade(tint, 1 - fade), .position = end + shake, @@ -1205,6 +1234,26 @@ void Scene::held_draw(Fighter& fighter) hand_draw(fighter, held, 0, draw); } +// Keeps a muzzle flash on the muzzle of the weapon that fired it as that weapon moves after, as it's drawn (the main +// hand's kicking back with its recoil). Once the fighter is gone or that hand holds something else, it stays put. +void Scene::flash_follow(Flash& flash) +{ + if (flash.owner >= game.sim.fighterCount) + return; + Fighter& fighter = game.sim.fighters[flash.owner]; + int slot = fighter.hand_slot_get(flash.hand); + if (!fighter.isAlive || slot < 0 || fighter.weaponLoadout[slot] != flash.weapon) + return; + float side = fighter.is_left() ? -1 : 1; + float kick = flash.hand == 0 ? kick_get(fighter) * side : 0; + Attack probe = {.origin = fighter.hand_get(flash.hand), + .angle = vec2_angle(fighter.aim) + kick, + .weapon = flash.weapon, + .isLeft = fighter.is_left()}; + flash.position = muzzle_get(probe); + flash.angle = probe.angle; +} + // Dual Wield: the off-hand weapons, a darker shade of the fighter's colour, fanned out behind it (see // Fighter::hand_get), the furthest back first so the nearer cover them. Each shakes and reloads like the main one. void Scene::offhand_draw(Fighter& fighter) @@ -1244,8 +1293,7 @@ void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw) 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); + draw.angle = vec2_angle(tether->position - draw.position); animation += " Base"; } else if (charge) diff --git a/src/scene.h b/src/scene.h index 61d9779..31d48d7 100644 --- a/src/scene.h +++ b/src/scene.h @@ -3,6 +3,7 @@ #include "anm2.h" #include "sim.h" +struct Flash; struct Game; // Shows the game this screen is on: draws its world, fighters and attacks, animates its fighters, and plays the cues it @@ -28,7 +29,8 @@ public: void burst_spawn(Burst burst, Vec2 position, float angle, int owner) override; 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 flash_spawn(Attack& attack, float size) override; + void flash_follow(Flash& flash); void fighter_afterimage_spawn(Fighter& fighter) override; void attack_afterimage_spawn(Attack& attack) override; void fighter_aura_spawn(Fighter& fighter) override; diff --git a/src/session.cpp b/src/session.cpp index a3e4c86..5b44159 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -51,7 +51,7 @@ void Session::start(Game& game) game.chat.players_clear(); bool isMaker = game.net.members.size() <= 1; game.chat.system((isMaker ? "Created lobby " : "Joined lobby ") + game.net.code + - ". Press T to chat, or type /help for commands."); + ". Type /help in chat for commands."); } // Back to playing alone: this screen's fighter (if it had one) stays, in a sandbox of its own. diff --git a/src/settings.cpp b/src/settings.cpp index c7cbd17..a8410bd 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -36,6 +36,7 @@ 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_HINTS_LEARNED = "hints_learned"; constexpr const char* KEY_LOADOUT = "loadout_"; constexpr const char* KEY_SERVER_URL = "server_url"; constexpr const char* KEY_RULES_OPEN = "rules_open"; @@ -43,6 +44,17 @@ 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_HUD_FPS = "hud_fps"; +constexpr const char* KEY_FPS_LIMIT = "fps_limit"; +constexpr const char* KEY_MUTE_UNFOCUSED = "mute_unfocused"; +constexpr const char* KEY_DAMAGE_NUMBERS = "damage_numbers"; +constexpr const char* KEY_RUMBLE = "rumble"; +constexpr const char* KEY_STICK_DEADZONE = "stick_deadzone"; +constexpr const char* KEY_WINDOW_SCALE = "window_scale"; +constexpr const char* KEY_CHAT = "chat"; +constexpr const char* KEY_PING = "ping"; +constexpr int DEADZONE_MAX = 60; +constexpr int DEADZONE_STEP = 5; constexpr const char* KEY_VSYNC = "vsync"; constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots"; constexpr const char* SETTINGS_FILE = "settings.txt"; @@ -57,6 +69,10 @@ constexpr const char* KEY_SANDBOX_Y = "sandbox_y"; constexpr const char* SANDBOX_START_WEAPON = "Sword"; constexpr float CLEAR_PROBE = 4; constexpr Uint64 WEB_SAVE_INTERVAL = 5000; +constexpr float STATS_COLUMN_WIDTH = 80; +constexpr const char* PLAY_NAMES[PLAY_KIND_COUNT] = {"Single Player", "Multiplayer"}; +constexpr const char* RANK_LABELS[ITEM_KIND_COUNT][RANK_ORDER_COUNT] = {{"Top Weapons", "Bottom Weapons"}, + {"Top Trinkets", "Bottom Trinkets"}}; static std::string file_path_get() { @@ -165,16 +181,6 @@ static int rules_write(Rules& rules, char* text, int size) return length; } -// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save. -static int item_find(ItemKind kind, const char* name) -{ - int count = item_count_get(kind); - for (int i = 0; i < count; i++) - 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). Returns how many entries // it read. @@ -268,6 +274,17 @@ static int sandbox_write(Fighter& fighter, char* text, int size) return length; } +// Steps value on to the next of a button's options when it's clicked, round to the first after the last. Returns +// whether it did. +template static bool cycle_step(bool isClicked, int& value, const int (&options)[COUNT]) +{ + if (!isClicked) + return false; + int index = std::find(std::begin(options), std::end(options), value) - std::begin(options); + value = options[(index + 1) % COUNT]; + return true; +} + Settings::Settings() { SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); @@ -302,6 +319,13 @@ void Settings::load(Game& game) SDL_strlcpy(serverUrl, value, sizeof(serverUrl)); if (SDL_strcmp(line, KEY_RULES_OPEN) == 0) isRulesOpen = number; + for (int i = 0; i < HINT_COUNT && SDL_strcmp(line, KEY_HINTS_LEARNED) == 0; i++) + { + const char* found = SDL_strstr(value, HINT_KEYS[i]); + size_t length = SDL_strlen(HINT_KEYS[i]); + bool isWhole = found && (found == value || found[-1] == ',') && (found[length] == ',' || !found[length]); + hintsLearned |= isWhole ? 1 << i : 0; + } if (SDL_strcmp(line, KEY_VOLUME) == 0) volume = std::clamp(number / PERCENT, 0.0f, 1.0f); if (SDL_strcmp(line, KEY_MUTE) == 0) @@ -314,6 +338,26 @@ void Settings::load(Game& game) isShaking = number; if (SDL_strcmp(line, KEY_HUD_DPS) == 0) isDpsShown = number; + if (SDL_strcmp(line, KEY_HUD_FPS) == 0) + isFpsShown = number; + if (SDL_strcmp(line, KEY_MUTE_UNFOCUSED) == 0) + isMutedUnfocused = number; + if (SDL_strcmp(line, KEY_DAMAGE_NUMBERS) == 0) + isDamageShown = number; + if (SDL_strcmp(line, KEY_RUMBLE) == 0) + isRumbling = number; + if (SDL_strcmp(line, KEY_STICK_DEADZONE) == 0) + stickDeadzone = std::clamp(number, 0, DEADZONE_MAX); + if (SDL_strcmp(line, KEY_CHAT) == 0) + isChatShown = number; + if (SDL_strcmp(line, KEY_PING) == 0) + isPingShown = number; + if (SDL_strcmp(line, KEY_WINDOW_SCALE) == 0 && + std::find(std::begin(WINDOW_SCALES), std::end(WINDOW_SCALES), number) != std::end(WINDOW_SCALES)) + windowScale = number; + if (SDL_strcmp(line, KEY_FPS_LIMIT) == 0 && + std::find(std::begin(FPS_LIMITS), std::end(FPS_LIMITS), number) != std::end(FPS_LIMITS)) + fpsLimit = number; if (SDL_strcmp(line, KEY_HUD_SPEED) == 0) isSpeedShown = number; if (SDL_strcmp(line, KEY_HUD_AIM) == 0) @@ -329,6 +373,7 @@ void Settings::load(Game& game) if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0) trinketSlots = std::clamp(number, 1, SLOT_MAX); rules_read(game.sim.match.rules, line, value); + game.stats.read(line, value); sandbox_read(sandbox, line, value); for (int i = 0; i < LOADOUT_COUNT; i++) { @@ -365,7 +410,16 @@ 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); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n", + KEY_MUTE_UNFOCUSED, isMutedUnfocused, KEY_DAMAGE_NUMBERS, isDamageShown, KEY_RUMBLE, + isRumbling, KEY_STICK_DEADZONE, stickDeadzone, KEY_WINDOW_SCALE, windowScale, KEY_CHAT, + isChatShown, KEY_PING, isPingShown); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n%s=%d\n%s=%d\n%s=", KEY_HUD_FPS, isFpsShown, + KEY_FPS_LIMIT, fpsLimit, KEY_FASHION, fashion, KEY_HINTS_LEARNED); + for (int i = 0, gap = 0; i < HINT_COUNT; i++) + if (hintsLearned & 1 << i) + length += SDL_snprintf(text + length, sizeof(text) - length, gap++ ? ",%s" : "%s", HINT_KEYS[i]); + length += SDL_snprintf(text + length, sizeof(text) - length, "\n"); for (int i = 0; i < CONTROL_COUNT; i++) { Binding binding = game.input.bindings[i]; @@ -376,6 +430,7 @@ 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); + length += game.stats.write(text + length, sizeof(text) - length); for (int i = 0; i < LOADOUT_COUNT; i++) { Loadout& loadout = loadouts[i]; @@ -401,9 +456,9 @@ void Settings::save(Game& game) void Settings::apply(Game& game) { - fullscreen_apply(game); + window_apply(game); game.renderer.vsync_set(isVsync); - game.audio.gain_set(isMuted ? 0 : volume); + game.input.aimDeadzone = stickDeadzone / PERCENT; if (game.sim.match.state == MATCH_PRACTICE) { Fighter& fighter = game.local_get(); @@ -411,9 +466,21 @@ void Settings::apply(Game& game) } } -// Puts the window in or out of fullscreen to match the setting. The web asks the browser to fill the screen instead, -// since SDL's own fullscreen there snaps the canvas into a corner. -void Settings::fullscreen_apply(Game& game) +// Sets the sound's loudness when it changes: the volume, or silence while muted (or, with Mute on Unfocus, while the +// window isn't focused). +void Settings::audio_apply(Game& game) +{ + bool isUnfocused = !(SDL_GetWindowFlags(game.window) & SDL_WINDOW_INPUT_FOCUS); + float gain = isMuted || (isMutedUnfocused && isUnfocused) ? 0 : volume; + if (gain != appliedGain) + game.audio.gain_set(gain); + appliedGain = gain; +} + +// Puts the window in or out of fullscreen to match the setting, and windowed, sizes it to its scale (fixed) or lets it +// be resized (Free). The web asks the browser to fill the screen instead, since SDL's own fullscreen there snaps the +// canvas into a corner, and the page sizes it. +void Settings::window_apply(Game& game) { #ifdef __EMSCRIPTEN__ EM_ASM( @@ -426,6 +493,9 @@ void Settings::fullscreen_apply(Game& game) isFullscreen); #else SDL_SetWindowFullscreen(game.window, isFullscreen); + SDL_SetWindowResizable(game.window, windowScale == 0); + if (!isFullscreen && windowScale > 0) + SDL_SetWindowSize(game.window, CANVAS_WIDTH * windowScale, CANVAS_HEIGHT * windowScale); #endif } @@ -433,7 +503,7 @@ void Settings::fullscreen_apply(Game& game) void Settings::fullscreen_toggle(Game& game) { isFullscreen = !isFullscreen; - fullscreen_apply(game); + window_apply(game); save(game); } @@ -495,34 +565,57 @@ void Settings::draw(Game& game, Rect area, Page& page) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Window"); bool isFullscreenChanged = false; bool isVsyncChanged = false; - if (ui.row_get(page, ROW_HEIGHT, y)) - { - 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); + 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}, "Shakes the screen on\nbig hits and blasts."); + isFullscreenChanged = ui.toggle_draw(game, {x, y}, "Fullscreen", isFullscreen); + isShakeChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Screenshake", isShaking); +#ifndef __EMSCRIPTEN__ + // The window's scale, stepping through WINDOW_SCALES, unused in fullscreen. + Rect scale = {x + TOGGLE_SPACING * 2, y, NUMBER_WIDTH, WIDGET_HEIGHT}; + ui.hint_set(game, scale, "The window's size, in\nwhole steps, or Free\nto drag it to any size."); + char label[LINE_LENGTH]; + SDL_snprintf(label, sizeof(label), windowScale > 0 ? "%dx Scale" : "Free Scale", windowScale); + ui.widgets_enable(!isFullscreen); + if (cycle_step(ui.button_draw(game, scale, label) && !isFullscreen, windowScale, WINDOW_SCALES)) + isFullscreenChanged = true; + ui.widgets_enable(true); +#endif + } + + // The frame rate: its meter, the limit (stepping through FPS_LIMITS, unused while vsync paces the frames) and + // vsync. + bool isHudChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + Rect limit = {x + TOGGLE_SPACING, y, NUMBER_WIDTH, WIDGET_HEIGHT}; + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shows how many frames\ndraw each second."); + ui.hint_set(game, limit, "The most frames drawn\neach second, unless\nVsync is on."); + ui.hint_set(game, {x + TOGGLE_SPACING * 2, y, TOGGLE_SPACING, row.h}, + "Matches frames to your\nscreen, stopping tearing."); + isHudChanged |= ui.toggle_draw(game, {x, y}, "FPS", isFpsShown); + char label[LINE_LENGTH]; + SDL_snprintf(label, sizeof(label), fpsLimit > 0 ? "%d FPS" : "Unlimited", fpsLimit); + ui.widgets_enable(!isVsync); + isDirty |= cycle_step(ui.button_draw(game, limit, label) && !isVsync, fpsLimit, FPS_LIMITS); + ui.widgets_enable(true); + isVsyncChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING * 2, y}, "Vsync", isVsync); } if (isFullscreenChanged) - fullscreen_apply(game); + window_apply(game); if (isVsyncChanged) game.renderer.vsync_set(isVsync); if (ui.row_get(page, ROW_HEIGHT, y)) ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay"); - bool isHudChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) { 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); + isHudChanged |= ui.toggle_draw(game, {x, y}, "DPS", isDpsShown); + isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed", isSpeedShown); } if (ui.row_get(page, ROW_HEIGHT, y)) { @@ -531,6 +624,11 @@ void Settings::draw(Game& game, Rect area, Page& page) isHudChanged |= ui.toggle_draw(game, {x, y}, "Aim Preview", isAimShown); isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Display Collision", isCollisionShown); } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {x, y, TOGGLE_SPACING * 2, row.h}, "Pops up the damage\nof each hit."); + isHudChanged |= ui.toggle_draw(game, {x, y}, "Damage Numbers", isDamageShown); + } if (ui.row_get(page, ROW_HEIGHT, y)) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Audio"); @@ -547,10 +645,16 @@ void Settings::draw(Game& game, Rect area, Page& page) if (ui.row_get(page, ROW_HEIGHT, y)) { ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Silences all sound."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING * 2, row.h}, + "Silences all sound while\nthe game isn't focused."); isSound |= ui.toggle_draw(game, {x, y}, "Mute", isMuted); + isSound |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Mute on Unfocus", isMutedUnfocused); } - if (isSound) - game.audio.gain_set(isMuted ? 0 : volume); + + 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 * 2, y)) + online_draw(game, {area.x, y, row.w, WIDGET_HEIGHT}, LABEL_WIDTH); controls_draw(game, page, row); ui.page_end(game, page); @@ -632,6 +736,25 @@ void Settings::controls_draw(Game& game, Page& page, Rect row) } } } + + // How the gamepad feels: rumble, and how far its aim stick must lean to aim. + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {keyX, y, TOGGLE_SPACING, row.h}, "Shakes your gamepad\nwhen you're hit."); + isDirty |= ui.toggle_draw(game, {keyX, y}, "Rumble", isRumbling); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {row.x, y, row.w, row.h}, + "How far the aim stick\nleans before it aims.\nRaise it if it drifts."); + ui.label_draw(game, "Stick Deadzone", {row.x, y}); + if (ui.number_draw(game, {keyX, y, NUMBER_WIDTH, WIDGET_HEIGHT}, stickDeadzone, + {.min = 0, .max = DEADZONE_MAX, .step = DEADZONE_STEP, .suffix = "%"})) + { + input.aimDeadzone = stickDeadzone / PERCENT; + isDirty = true; + } + } } // The colour this screen's player asks for in a match: their pick, the session's random roll, or none. @@ -653,24 +776,41 @@ void Settings::defaults_restore(Game& game) isCollisionShown = fresh.isCollisionShown; isDpsShown = fresh.isDpsShown; isSpeedShown = fresh.isSpeedShown; + isFpsShown = fresh.isFpsShown; + fpsLimit = fresh.fpsLimit; isAimShown = fresh.isAimShown; + isMutedUnfocused = fresh.isMutedUnfocused; + isDamageShown = fresh.isDamageShown; + windowScale = fresh.windowScale; + isChatShown = fresh.isChatShown; + isPingShown = fresh.isPingShown; SDL_strlcpy(serverUrl, fresh.serverUrl, sizeof(serverUrl)); weaponSlots = fresh.weaponSlots; trinketSlots = fresh.trinketSlots; - fullscreen_apply(game); + window_apply(game); game.renderer.vsync_set(isVsync); - game.audio.gain_set(isMuted ? 0 : volume); if (game.sim.match.state == MATCH_PRACTICE) game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); isDirty = true; } -// 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) +// The online settings, two rows from row (here and on the Multiplayer page, each with its own label width): the lobby +// server's host, typed into a field, going back to the default when emptied and saved once it's no longer being typed +// in, then whether other players' chat and everyone's pings show. +void Settings::online_draw(Game& game, Rect row, float labelWidth) { Ui& ui = game.ui; + Rect field = {row.x + labelWidth, row.y, row.w - labelWidth, WIDGET_HEIGHT}; + ui.hint_set(game, row, "The server hosting lobbies.\nEmpty it to reset."); + ui.label_draw(game, "Host", {row.x, row.y}); + float y = row.y + ROW_HEIGHT; + ui.hint_set(game, {field.x, y, TOGGLE_SPACING, WIDGET_HEIGHT}, + "Shows other players'\nmessages. Off, you can\nstill send your own."); + ui.hint_set(game, {field.x + TOGGLE_SPACING, y, TOGGLE_SPACING, WIDGET_HEIGHT}, + "Shows players'\nconnection quality."); + isDirty |= ui.toggle_draw(game, {field.x, y}, "Chat", isChatShown); + isDirty |= ui.toggle_draw(game, {field.x + TOGGLE_SPACING, y}, "Ping", isPingShown); 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)); @@ -751,6 +891,7 @@ void Settings::profile_draw(Game& game, Rect area, Page& page) 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); + stats_draw(game, page, {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 @@ -828,6 +969,7 @@ void Settings::update(Game& game) isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; }); #endif wasPractice = isPractice; + audio_apply(game); loadouts_update(game); if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window)) save(game); @@ -980,3 +1122,57 @@ void Settings::loadouts_draw(Game& game, Rect row, Tooltip& hovered) } } } + +// Profile's record (see Stats) under a separator, a page row each: the totals, then the most and least played weapons +// and trinkets, any never played left out, each hovering to its tooltip at its sandbox level. +void Settings::stats_draw(Game& game, Page& page, Rect row, Tooltip& hovered) +{ + Ui& ui = game.ui; + Stats& stats = game.stats; + Match& match = game.sim.match; + float& y = row.y; + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, row, "Stats"); + float value = row.x + LABEL_WIDTH; + char text[LINE_LENGTH]; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Damage Dealt", {row.x, y}); + SDL_snprintf(text, sizeof(text), "%lld", (long long)stats.damage); + ui.label_draw(game, text, {value, y}); + } + for (int play = 0; play < PLAY_KIND_COUNT; play++) + { + if (!ui.row_get(page, ROW_HEIGHT, y)) + continue; + ui.label_draw(game, PLAY_NAMES[play], {row.x, y}); + SDL_snprintf(text, sizeof(text), "Kills: %d", stats.kills[play]); + ui.label_draw(game, text, {value, y}); + SDL_snprintf(text, sizeof(text), "Wins: %d", stats.wins[play]); + ui.label_draw(game, text, {value + STATS_COLUMN_WIDTH, y}); + } + + Fighter& sandbox = match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox; + float size = ICON_PIXELS[ICON_SMALL]; + for (ItemKind kind : ITEM_KINDS) + { + for (int order = 0; order < RANK_ORDER_COUNT; order++) + { + if (!ui.row_get(page, ROW_HEIGHT, y)) + continue; + int items[STATS_RANKED]; + int count = stats.ranked_get(kind, (RankOrder)order, items); + ui.label_draw(game, RANK_LABELS[kind][order], {row.x, y}); + if (count == 0) + ui.label_draw(game, "None yet", {value, y}); + for (int i = 0; i < count; i++) + { + Vec2 centre = {value + i * (size + LOADOUT_GAP) + size / 2, y + WIDGET_HEIGHT / 2}; + 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], sandbox.item_levels_get(kind)[items[i]]); + } + } + } +} diff --git a/src/settings.h b/src/settings.h index 3175aaa..3c45cce 100644 --- a/src/settings.h +++ b/src/settings.h @@ -28,6 +28,10 @@ struct SandboxSave }; constexpr int LOADOUT_COUNT = 9; +// The frame rate limits the FPS limit button steps through, 0 for none. +constexpr int FPS_LIMITS[] = {60, 144, 0}; +// The window scales the Scale button steps through, 0 for Free (resized by hand). +constexpr int WINDOW_SCALES[] = {1, 2, 3, 4, 0}; // 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). @@ -55,21 +59,35 @@ class Settings public: char name[NAME_LENGTH + 1] = "Fighter"; float volume = 1; + // The most frames drawn each second (see FPS_LIMITS), 0 for no limit. Vsync paces them instead while it's on. + int fpsLimit = 0; + // How far the aim stick must lean before it aims, in percent. + int stickDeadzone = 30; + // The window's size in canvases (see WINDOW_SCALES), 0 for Free. + int windowScale = 0; int color = COLOR_UNASSIGNED; int fashion = FASHION_NONE; int trinketSlots = 2; int weaponSlots = 2; bool isAimShown = true; + bool isChatShown = true; bool isCollisionShown = false; + bool isDamageShown = true; bool isDpsShown = false; + bool isFpsShown = false; bool isFullscreen = false; bool isMuted = false; + bool isMutedUnfocused = true; + bool isPingShown = true; + bool isRumbling = true; bool isShaking = true; bool isSpeedShown = false; bool isVsync = true; // The server online play goes through (see protocol.h), and whether the Play tab's rules are unfolded. char serverUrl[SERVER_URL_LENGTH] = {}; bool isRulesOpen = true; + // The first-run hints already learned, a bit per Hint. + int hintsLearned = 0; // The saved loadouts, and which one the loadout picker shows (counting from 1). Loadout loadouts[LOADOUT_COUNT]; int loadoutShown = 1; @@ -78,11 +96,12 @@ public: void load(Game& game); void save(Game& game); void apply(Game& game); - void fullscreen_apply(Game& game); + void window_apply(Game& game); + void audio_apply(Game& game); void fullscreen_toggle(Game& game); void draw(Game& game, Rect area, Page& page); void profile_draw(Game& game, Rect area, Page& page); - void host_draw(Game& game, Rect field); + void online_draw(Game& game, Rect row, float labelWidth); void defaults_restore(Game& game); int color_get(); // Marks the settings for saving (as the menu does), for changes made elsewhere, like chat commands. @@ -93,6 +112,7 @@ public: void restore(Game& game); void update(Game& game); void loadouts_draw(Game& game, Rect row, Tooltip& hovered); + void stats_draw(Game& game, Page& page, Rect row, Tooltip& hovered); private: SandboxSave sandbox; @@ -100,6 +120,7 @@ private: Uint64 savedAt = 0; bool isDirty = false; bool wasPractice = true; + float appliedGain = -1; int loadoutPasses = 0; int loadoutPending = -1; diff --git a/src/sim.cmake b/src/sim.cmake index de84872..5fe4c1c 100644 --- a/src/sim.cmake +++ b/src/sim.cmake @@ -16,3 +16,41 @@ if(MSVC) else() target_compile_options(sim PUBLIC -ffp-contract=off -fno-fast-math) endif() + +# Each weapon's muzzle, where its shots leave it, from the "Muzzle" null of its animation in weapons.anm2 (the art never +# loads into the simulation, so it's read here, again whenever the file changes). It reaches weapon.cpp as WEAPON_MUZZLES, +# a {"name", x, y} entry for each weapon that has one. +set(WEAPONS_ANM2 ${CMAKE_CURRENT_LIST_DIR}/../resources/gfx/weapons.anm2) +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${WEAPONS_ANM2}) +file(READ ${WEAPONS_ANM2} weaponsArt) +set(muzzleNull -1) +string(JSON nullCount ERROR_VARIABLE noNulls LENGTH "${weaponsArt}" nulls) +if(NOT noNulls) + math(EXPR lastNull "${nullCount} - 1") + foreach(index RANGE ${lastNull}) + string(JSON nullName GET "${weaponsArt}" nulls ${index} name) + if(nullName STREQUAL "Muzzle") + set(muzzleNull ${index}) + endif() + endforeach() +endif() +set(muzzles "") +string(JSON animationCount LENGTH "${weaponsArt}" animations) +math(EXPR lastAnimation "${animationCount} - 1") +foreach(index RANGE ${lastAnimation}) + string(JSON animation MEMBER "${weaponsArt}" animations ${index}) + string(JSON trackCount ERROR_VARIABLE noTracks LENGTH "${weaponsArt}" animations ${animation} nulls) + if(noTracks OR trackCount EQUAL 0) + continue() + endif() + math(EXPR lastTrack "${trackCount} - 1") + foreach(track RANGE ${lastTrack}) + string(JSON null GET "${weaponsArt}" animations ${animation} nulls ${track} null) + if(null EQUAL muzzleNull) + string(JSON x GET "${weaponsArt}" animations ${animation} nulls ${track} frames 0 pos 0) + string(JSON y GET "${weaponsArt}" animations ${animation} nulls ${track} frames 0 pos 1) + string(APPEND muzzles "{\"${animation}\",${x},${y}},") + endif() + endforeach() +endforeach() +target_compile_definitions(sim PRIVATE "WEAPON_MUZZLES=${muzzles}") diff --git a/src/sim.h b/src/sim.h index 4e450a5..0ea1f84 100644 --- a/src/sim.h +++ b/src/sim.h @@ -21,7 +21,7 @@ public: {} virtual void shake_add(float amount) {} - virtual void flash_spawn(Vec2 position, float angle, float size, int owner) + virtual void flash_spawn(Attack& attack, float size) {} virtual void fighter_afterimage_spawn(Fighter& fighter) {} diff --git a/src/stats.cpp b/src/stats.cpp new file mode 100644 index 0000000..67f96f2 --- /dev/null +++ b/src/stats.cpp @@ -0,0 +1,123 @@ +#include "stats.h" +#include "game.h" +#include +#include + +constexpr const char* KEY_STATS_DAMAGE = "stats_damage"; +constexpr const char* KEY_STATS_KILLS = "stats_kills"; +constexpr const char* KEY_STATS_WINS = "stats_wins"; +constexpr const char* KEY_STATS_TIMES[ITEM_KIND_COUNT] = {"stats_weapon_times", "stats_trinket_times"}; + +// Takes in what this screen's fighter has newly dealt, killed and won, and adds a tick to the weapon it holds and each +// trinket it wears while it plays out of the menu. +void Stats::update(Game& game) +{ + if (game.session.is_watching(game)) + return; + Fighter& fighter = game.local_get(); + Match& match = game.sim.match; + PlayKind play = match.isOnline ? PLAY_MULTI : PLAY_SINGLE; + bool isFresh = fighter.damageDealt == 0 && fighter.kills == 0 && fighter.wins == 0; + if (!isFresh) + { + damage += fighter.damageDealt - seenDamage; + kills[play] += fighter.kills - seenKills; + wins[play] += fighter.wins - seenWins; + } + if (fighter.wins != seenWins) + game.settings.changed(); + seenDamage = fighter.damageDealt; + seenKills = fighter.kills; + seenWins = fighter.wins; + + bool isPlaying = match.state == MATCH_PRACTICE || match.state == MATCH_FIGHT; + if (!isPlaying || game.menu.isOpen || !fighter.isAlive) + return; + int weapon = fighter.weaponLoadout[fighter.slot]; + if (weapon >= 0) + weaponTimes[weapon] += TICK; + int* worn = fighter.trinketLoadout; + for (int i = 0; i < fighter.trinketSlots; i++) + if (worn[i] >= 0 && std::find(worn, worn + i, worn[i]) == worn + i) + trinketTimes[worn[i]] += TICK; +} + +// Reads one saved line, if it's a stat's: totals as "single,multi", times as "name:seconds" by item. +void Stats::read(const char* key, char* value) +{ + if (SDL_strcmp(key, KEY_STATS_DAMAGE) == 0) + damage = SDL_strtod(value, nullptr); + const char* comma = SDL_strchr(value, ','); + int* pairs[] = {SDL_strcmp(key, KEY_STATS_KILLS) == 0 ? kills : nullptr, + SDL_strcmp(key, KEY_STATS_WINS) == 0 ? wins : nullptr}; + for (int* pair : pairs) + { + if (!pair || !comma) + continue; + pair[PLAY_SINGLE] = SDL_atoi(value); + pair[PLAY_MULTI] = SDL_atoi(comma + 1); + } + + for (ItemKind kind : ITEM_KINDS) + { + if (SDL_strcmp(key, KEY_STATS_TIMES[kind]) != 0) + continue; + char* rest = nullptr; + for (char* entry = SDL_strtok_r(value, ",", &rest); entry; entry = SDL_strtok_r(nullptr, ",", &rest)) + { + char* colon = SDL_strchr(entry, ':'); + if (!colon) + continue; + *colon++ = '\0'; + int item = item_find(kind, entry); + if (item >= 0) + times_get(kind)[item] = SDL_strtod(colon, nullptr); + } + } +} + +// Writes the stats as saved lines, returning their length. Items never played are left out. +int Stats::write(char* text, int size) +{ + int length = + SDL_snprintf(text, size, "%s=%lld\n%s=%d,%d\n%s=%d,%d\n", KEY_STATS_DAMAGE, (long long)damage, KEY_STATS_KILLS, + kills[PLAY_SINGLE], kills[PLAY_MULTI], KEY_STATS_WINS, wins[PLAY_SINGLE], wins[PLAY_MULTI]); + for (ItemKind kind : ITEM_KINDS) + { + double* times = times_get(kind); + length += SDL_snprintf(text + length, size - length, "%s=", KEY_STATS_TIMES[kind]); + const char* gap = ""; + for (int item = 0; item < item_count_get(kind); item++) + { + if (times[item] <= 0) + continue; + length += + SDL_snprintf(text + length, size - length, "%s%s:%.1f", gap, item_name_get(kind, item), times[item]); + gap = ","; + } + length += SDL_snprintf(text + length, size - length, "\n"); + } + return length; +} + +// Fills items with up to STATS_RANKED of a kind's most or least played, leaving out any never played, and returns how +// many. +int Stats::ranked_get(ItemKind kind, RankOrder order, int* items) +{ + double* times = times_get(kind); + int played[std::max((int)WEAPON_COUNT, (int)TRINKET_COUNT)]; + int count = 0; + for (int item = 0; item < item_count_get(kind); item++) + if (times[item] > 0) + played[count++] = item; + std::sort(played, played + count, + [&](int a, int b) { return order == RANK_TOP ? times[a] > times[b] : times[a] < times[b]; }); + count = std::min(count, STATS_RANKED); + std::copy(played, played + count, items); + return count; +} + +double* Stats::times_get(ItemKind kind) +{ + return kind == ITEM_TRINKET ? trinketTimes : weaponTimes; +} diff --git a/src/stats.h b/src/stats.h new file mode 100644 index 0000000..dcaa9f9 --- /dev/null +++ b/src/stats.h @@ -0,0 +1,51 @@ +#pragma once + +#include "trinket.h" +#include "weapon.h" + +struct Game; + +// Where a stat was earned: offline (against bots, or with others on this screen) or online. +enum PlayKind +{ + PLAY_SINGLE, + PLAY_MULTI, + PLAY_KIND_COUNT +}; + +enum RankOrder +{ + RANK_TOP, + RANK_BOTTOM, + RANK_ORDER_COUNT +}; + +// How many items each of Profile's most and least played rows shows. +constexpr int STATS_RANKED = 3; + +// The player's lifetime record, shown in Profile: damage dealt anywhere, deathmatch kills and matches won by where they +// were played, and how long each weapon was held and each trinket worn while playing outside the menu. Damage, kills +// and wins come from the running totals this screen's fighter keeps in the game, taken as they change, so a rewind +// online takes back what it undoes. A fighter whose totals are all zero is a fresh one, and counting starts over from +// it. Saved with the settings. +class Stats +{ +public: + double damage = 0; + int kills[PLAY_KIND_COUNT] = {}; + int wins[PLAY_KIND_COUNT] = {}; + + void update(Game& game); + void read(const char* key, char* value); + int write(char* text, int size); + int ranked_get(ItemKind kind, RankOrder order, int* items); + +private: + double trinketTimes[TRINKET_COUNT] = {}; + double weaponTimes[WEAPON_COUNT] = {}; + float seenDamage = 0; + int seenKills = 0; + int seenWins = 0; + + double* times_get(ItemKind kind); +}; diff --git a/src/trinket.h b/src/trinket.h index 3eda460..4ff6058 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -1,5 +1,6 @@ #pragma once +#include #include "weapon.h" struct Game; @@ -299,3 +300,12 @@ inline const char* item_name_get(ItemKind kind, int item) { return kind == ITEM_TRINKET ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name; } + +// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save. +inline int item_find(ItemKind kind, const char* name) +{ + for (int i = 0; i < item_count_get(kind); i++) + if (std::strcmp(name, item_name_get(kind, i)) == 0) + return i; + return -1; +} diff --git a/src/ui.cpp b/src/ui.cpp index 997273b..e86cd4f 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -54,6 +54,8 @@ constexpr float CHARGE_BAR_HEIGHT = 8; constexpr float CHARGE_BAR_WIDTH = 20; constexpr float DPS_SCALE = 2; constexpr float FIGHT_BANNER_TIME = 0.6f; +// How many of the countdown's last seconds beep (and, in a timed round, show big). +constexpr int COUNTDOWN_BEEPS = 5; constexpr float HEALTH_BAR_HEIGHT = 7; constexpr float HEALTH_BAR_WIDTH = 28; constexpr float ROUND_SCALE = 3; @@ -114,6 +116,14 @@ constexpr float FIELD_INSET = 4; constexpr float SLIDER_INSET = 3; constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2; constexpr float HINT_FADE = 0.5f; +constexpr const char* HINT_SPACING = " "; +// How wide a row of first-run hints may run, clear of the hotbars in the corners. +constexpr float HINT_ROW_WIDTH = 360; +// Each first-run hint's control (Move takes Move Right too) and what it does. +constexpr Control HINT_CONTROLS[HINT_COUNT] = {CONTROL_LEFT, CONTROL_JUMP, CONTROL_DOWN, CONTROL_ATTACK, + CONTROL_CHARGE, CONTROL_MENU, CONTROL_READY, CONTROL_CHAT}; +constexpr const char* HINT_LABELS[HINT_COUNT] = {"Move", "Jump", "Crouch / Drop", "Attack", + "Charged Attack", "Menu", "Reset", "Chat"}; constexpr float TOOLTIP_OFFSET = 12; constexpr float TOOLTIP_PADDING = 6; constexpr float TABLE_GAP = 8; @@ -132,44 +142,107 @@ constexpr Vec2 LABEL_OFFSETS[ICON_TINY] = {{4, 2}, {3, 2}}; constexpr Vec2 ICON_LABEL_OFFSET = {LABEL_OFFSETS[ICON_FULL].x - (SLOT_SIZE - ICON_SIZE) / 2, LABEL_OFFSETS[ICON_FULL].y - (SLOT_SIZE - ICON_SIZE) / 2}; +// Tips shown under the ready button, one at random: movement and knockback, cooldowns and Charge, projectiles, each +// Weapon and Trinket in turn, then the menus. constexpr const char* TIPS[] = { - "Big hits launch fighters, and slamming into walls while launched hurts.", - "Swing the Hammer into a wall to rebound off it, even mid-launch.", - "Ranged shots lose damage the farther they fly. Get close.", - "Explosions hit hardest at their centre.", - "Your own explosions push you but never hurt you. Rocket jump!", - "Arrows stuck in walls are solid to you. Stand on them.", - "Charged boomerangs are platforms only you can ride.", - "Standing in your own flames brakes you, even mid-launch.", - "Shots carry your momentum. Fire while charging forward for extra damage.", - "Every hit is stronger the faster you're moving.", - "Let go of a charge early for a normal attack that hits harder.", - "Mines need a moment to arm before they'll go off.", - "The Whip's charged blast only lands at the very tip.", - "A charged grapple hit drags you straight to your target.", - "Platforms block shots. Use them as cover.", - "With Iron Weights, hold Down to slam the ground. Faster falls hit harder.", - "Ricochet shots add damage with every bounce.", - "Your charge carries over when you swap weapons.", - "Charge recalls your boomerangs once they're all out.", - "Attack or Charge reels a thrown flail ball back in.", - "Uzi and Blaster hits barely push. Melee and explosions send fighters flying.", - "Rapid small hits can't juggle you. Knockback tops up, it doesn't stack.", - "Crack the Whip against a wall to be pulled toward it.", - "Recoil is movement. Fire away from where you want to go.", - "Splitter copies fan wider with each level.", - "Dual Wield fires your previous weapon from your off hand too.", - "Tesla Coil chains lightning between your weapons out in the world.", - "Each round levels up every item, and health rises with it.", - "With the Feather, hold Up in the air to float.", - "The Lance's charged poke lunges you forward.", - "In a race, dithered pits send you back to the last solid ground you stood on.", - "Race blocks grow back a few seconds after they break. Don't dawdle.", - "Hits in a race don't hurt, but they still knock. Push rivals into pits.", - "Shotgun recoil can double as a jump.", + "Getting knocked into walls hurts! Do your best to reduce your speed when attacked!", + "Don't get cornered! Knockback damage can add up fast!", + "Crouch to reduce knockback!", + "Crawl around by crouching and moving!", + "Roll around on sloped surfaces with Down!", + "Platforms block shots. Use them as cover!", + + "Weapons don't share cooldowns! Switch Weapons to maximize damage!", + "When a Weapon enters cooldown, it will fade and return to normal when it's done!", + "Your cursor's color and vibration can show you your Weapon's cooldown or Charge!", + "Let go of a Charge early for a Basic Attack that hits harder!", + "Your Charge carries over when you swap Weapons!", + "Higher damaging attacks will glow and vibrate!", + "Going faster makes you glow and vibrate, showing that your attacks deal more damage!", + + "Almost every attack counts as a projectile! See how you can change their traits with Trinkets!", + "Projectiles inherit your velocity. Go fast for big damage!", + "Projectiles lose damage the longer they're out. Get close!", + "Projectiles will fade with time; this fade shows their damage potential!", + "Increasing a projectile's lifetime or speed can both increase its range!", + "Explosions hit hardest at their center!", + "Your explosions don't hurt, and only push you. Try rocket jumping!", + "Recoil is movement. Fire away from where you want to go!", + + "Mix and match Weapons and Trinkets for nigh-infinite amounts of combos!", + "The Fist's Charged Attack doesn't always send you straight up! Change your aim!", + "Slam a Hammer into surfaces to fly far!", + "The Lance's Charged Attack lunges you forward!", + "Crack the Whip against a wall to be pulled toward it!", + "The Whip's Charged Attack only blasts at the very tip, but does big damage!", + "Charge a Flail to throw its ball, and Charge again to reel it back!", + "You can have the Flail's ball extended and another Weapon equipped for a dangerous combo!", + "Charged Boomerangs are platforms only you can ride!", + "Charge recalls your Boomerangs!", + "A Grapple's Charged Attack yanks you to your target!", + "Swing around on or release a latched Grapple for big speed!", + "You can have a Grapple latched and another Weapon equipped for an acrobatic fight!", + "A Bow's arrows stuck in walls are semi-solid platforms!", + "The Bow's arrows fly faster and further with Charge!", + "Fire a wide volley with the Uzi's Charged Attack!", + "Use a Shotgun's recoil for great movement!", + "The Bolter's projectiles can cling onto surfaces, perfect for area denial!", + "Cook a Grenade Launcher's grenade via Charging to explode it sooner!", + "Fully Charging a Grenade Launcher will cause an explosion in your face!", + "Hit the Grenade Launcher's projectile and explosion at the same time for big damage! Double Donk!", + "Mines need a moment to arm before they'll go off!", + "The Flamethrower's Charged Attack detonates mid-air! Space an explosion!", + "The Vacuum's Charged Attack deals big knockback! Send foes flying!", + "The Vacuum's Charged Attack turns enemy shots into your own!", + "Hold your own projectiles in the Vacuum's suction to keep them fresh!", + "The Vacuum's suction and Charged Attack can move projectiles around. Manipulate the battlefield!", + "Use the Grimoire's wisps as a close-range attack or to pepper from afar!", + "Use the Grimoire's teleporting Charged Attack to escape danger!", + + "Diazepam reduces Weapon spread, perfect for concentrated attacks!", + "Splitter copies fan wider with each level!", + "The off-hand Weapon from Dual Wield is the one you last switched from!", + "Ricochet makes projectiles deal more damage the more they've bounced!", + "Ricochet bounces reset a projectile's lifetime!", + "Tesla Coil arcs electricity between your projectiles. Make traps or wide danger zones!", + "The kickback the Recoil Coil gives is perfect for movement!", + "Stay in place with a Shock Absorber!", + "Embiggener makes you a bigger target, but gives a solid damage buff!", + "Make yourself a living hazard with a Pebble Helmet!", + "With a Speedometer, moving fast and attacking deals bigger damage!", + "A pair of Wings will make you dash to where you're aiming when you jump mid-air!", + "With the Feather, hold Up in the air to float!", + "With Iron Weights, faster falls produce bigger blasts!", + "Cling, slide, and kick off walls with a Climb Claw!", + + "Create a Lobby and share its link or code with friends for multiplayer fun!", + "When in a Lobby, queue up a game in the menu!", + "Queuing takes place in the Sandbox! Holler fighters over to your queued area and Ready up!", + "Change a game's rules underneath the Rules option!", + "If a game has a time limit, the winner will be decided based on who has the most health!", + "Is your name \"Fighter\"? Change it in Profile!", + "Change your fighter and HUD color in Profile!", + "Feeling naked and unsexy? Wear some Fashion within your Profile!", + "Set and load custom gear Loadouts in your Profile!", + "Save a gear Loadout and use Shift + a number to equip it!", + "Turn on DPS in Settings to see the damage you deal each second!", + "Turn on Speed in Settings to see how fast you're going!", + "Turn on FPS in Settings to see how smoothly the game runs!", + "Is previewing your aim too intrusive? Toggle Aim Preview off in Settings!", + "Want to see how things actually collide? Toggle Display Collision in Settings!", + "Game too loud? Turn down the Volume or Mute it in Settings!", + "Don't like the Controls? Tweak them to your liking in Settings!", + "Want to experiment with more Weapon or Trinket slots in the Sandbox? Change them in Settings!", + "Want to fight in the Sandbox with others? Check PvP in Settings or use the /pvp command!", + "The Dummy in the Sandbox doesn't feel pain! Beat it as much as you'd like, it enjoys it!", + "Throw tomatoes to mock a game's winner in between rounds by clicking anywhere on screen!", + "Sling slurs and trashtalk all you wish with a Chat message!", + "A number of useful / commands are present in Chat! Type /help to see!", + "Want to see the sexy person who made this game? Check About in the menu!", }; static CountdownInfo countdown_info_get(Game& game); +static float rival_ping_get(Game& game, int fighter); // 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) @@ -287,7 +360,7 @@ void Ui::bars_draw(Game& game) const char* name = name_get(game, i); Vec2 size = game.text.size_get(name); bottom -= size.y; - float ping = game.session.isActive ? game.session.ping_get(game, match.owners[i]) : PING_NONE; + float ping = rival_ping_get(game, i); float width = size.x + (ping == PING_NONE ? 0 : NAME_PING_GAP + ping_width_get(game, ping)); float left = std::round(centerX - width / 2); game.text.draw(renderer, name, {.color = color, .position = {left, bottom}}); @@ -314,7 +387,7 @@ void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float s color); } -// In practice, a hint to open the menu (with its key), fading once it first opens. In a match, the round wins and +// In practice, the first-run hints (see hints_draw). In a match, the round wins and // clock along the top, and a banner for the countdown (with who's ready, a Ready button and a tip), the start of the // fight, the end of a round and the end of the match. void Ui::match_draw(Game& game) @@ -325,12 +398,7 @@ void Ui::match_draw(Game& game) Input& input = game.input; if (match.state == MATCH_PRACTICE) { - char key[TOOLTIP_LENGTH]; - char reset[TOOLTIP_LENGTH]; - 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); + hints_draw(game); return; } @@ -340,8 +408,8 @@ void Ui::match_draw(Game& game) else { // The score: each fighter's wins in its own colour, the dashes between them white. It's centred along the top, - // unless a long weapon hotbar reaches it, when it moves right to clear the hotbar (the clock under it moves - // with it). + // unless a long weapon hotbar reaches it in the HUD, when it moves right to clear the hotbar (the clock under + // it moves with it). It stays put while the menu moves the hotbar. Renderer& renderer = game.renderer; int length = 0; for (int i = 0; i < game.sim.fighterCount; i++) @@ -349,7 +417,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(ITEM_WEAPON, weapons - 1, weapons); + Rect last = game.menu.slot_hud_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; @@ -379,6 +447,7 @@ void Ui::match_draw(Game& game) HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}}); } + countdown_beep(game); switch (match.state) { case MATCH_COUNTDOWN: @@ -439,6 +508,12 @@ void Ui::match_draw(Game& game) } if (match.timer > -FIGHT_BANNER_TIME) text_center_draw(game, isRace ? "Go!" : "Fight!", BANNER_Y, BANNER_SCALE); + // A timed round's last seconds count down big. + if (match.time_limit_get() > 0 && countdownBeeped > 0) + { + SDL_snprintf(text, sizeof(text), "%d", countdownBeeped); + text_center_draw(game, text, BANNER_Y, BANNER_SCALE); + } break; case MATCH_ROUND_OVER: if (match.winner >= 0) @@ -828,6 +903,108 @@ static CountdownInfo countdown_info_get(Game& game) return info; } +// Marks a first-run hint learned once this screen's fighter has done what it says (a full charge for the charged +// attack), keeping it in the settings across launches, and fades learned ones out. +void Ui::hints_update(Game& game) +{ + Fighter& local = game.local_get(); + int& learned = game.settings.hintsLearned; + bool isDone[HINT_COUNT] = { + local.is_down(CONTROL_LEFT) || local.is_down(CONTROL_RIGHT), + local.is_down(CONTROL_JUMP), + local.is_down(CONTROL_DOWN), + local.is_down(CONTROL_ATTACK), + local.charge >= 1, + game.menu.isOpen, + local.is_down(CONTROL_READY), + game.chat.isOpen, + }; + for (int i = 0; i < HINT_COUNT; i++) + { + int bit = 1 << i; + if (isDone[i] && !(learned & bit) && !game.session.is_watching(game)) + { + learned |= bit; + game.settings.changed(); + } + hintAlphas[i] = learned & bit ? std::max(0.0f, hintAlphas[i] - TICK / HINT_FADE) : 1; + } +} + +// The first-run hints still showing, each with its key, centred in rows along the bottom of the sandbox, a row going up +// when they'd run too wide for one. +void Ui::hints_draw(Game& game) +{ + Input& input = game.input; + char texts[HINT_COUNT][TOOLTIP_LENGTH]; + float widths[HINT_COUNT]; + int shown[HINT_COUNT]; + int count = 0; + for (int i = 0; i < HINT_COUNT; i++) + { + if (hintAlphas[i] <= 0 || (i == HINT_CHAT && !game.session.isActive)) + continue; + char key[TOOLTIP_LENGTH]; + char other[TOOLTIP_LENGTH]; + input.control_name_get(HINT_CONTROLS[i], key, sizeof(key)); + input.control_name_get(CONTROL_RIGHT, other, sizeof(other)); + if (i == HINT_MOVE) + SDL_snprintf(texts[count], sizeof(texts[count]), "%s/%s: %s", key, other, HINT_LABELS[i]); + else + SDL_snprintf(texts[count], sizeof(texts[count]), i == HINT_CHARGE ? "Hold %s: %s" : "%s: %s", key, + HINT_LABELS[i]); + widths[count] = game.text.size_get(texts[count]).x; + shown[count++] = i; + } + + // Rows fill greedily, then stack up from the bottom. + float gap = game.text.size_get(HINT_SPACING).x; + float limit = HINT_ROW_WIDTH; + int rowStarts[HINT_COUNT + 1]; + int rows = 0; + float width = limit; + for (int i = 0; i < count; i++) + { + if (width + gap + widths[i] > limit) + { + rowStarts[rows++] = i; + width = widths[i]; + continue; + } + width += gap + widths[i]; + } + rowStarts[rows] = count; + for (int row = 0; row < rows; row++) + { + float total = -gap; + for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) + total += widths[i] + gap; + float x = std::round((CANVAS_WIDTH - total) / 2); + float y = CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT * (rows - row); + for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) + { + game.text.draw(game.renderer, texts[i], + {.color = color_fade(tint, hintAlphas[shown[i]]), .position = {x, y}}); + x += widths[i] + gap; + } + } +} + +// The last COUNTDOWN_BEEPS seconds of the ready countdown, or of a timed round, beep as each number ticks over. +void Ui::countdown_beep(Game& game) +{ + Match& match = game.sim.match; + float left = 0; + if (match.state == MATCH_COUNTDOWN) + left = match.timer; + if (match.state == MATCH_FIGHT && match.time_limit_get() > 0) + left = match.time_limit_get() - match.clock; + int second = left > 0 && left <= COUNTDOWN_BEEPS ? (int)std::ceil(left) : 0; + if (second > 0 && second != countdownBeeped) + game.audio.play(SOUND_COUNTDOWN); + countdownBeeped = second; +} + // The countdown while the menu covers the middle (as when choosing items), drawn over the menu's dimming: the round and // seconds left in the margin left of the menu, who's ready in the margin right of it (with the key to ready up until // this screen has), and the tip under the hotbars, below the gamepad hints if they're showing. The round's name drops @@ -893,8 +1070,8 @@ void Ui::text_center_draw(Game& game, const char* text, float y, float scale, Co {.color = color, .position = {std::round((CANVAS_WIDTH - width) / 2), std::round(y)}, .scale = {scale, scale}}); } -// Top right: during a match, the local fighter's health as a number beside a bar, then damage per second and the -// local fighter's speed in units per second. +// Top right: during a match, the local fighter's health as a number beside a bar, then, smaller, frames drawn per +// second (counted over each second), damage per second and the local fighter's speed in units per second. void Ui::status_draw(Game& game) { char text[TOOLTIP_LENGTH]; @@ -918,13 +1095,26 @@ void Ui::status_draw(Game& game) y += rowHeight; } - // The DPS and speed meters each show only if they're on in the settings. + // The meters each show only if they're on in the settings. auto meter_draw = [&](const char* label) { - float x = std::round(right - game.text.size_get(label, FONT_SMALL, DPS_SCALE).x); + float x = std::round(right - game.text.size_get(label).x); left = std::min(left, x); - game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}, .scale = {DPS_SCALE, DPS_SCALE}}); - y += rowHeight; + game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}}); + y += game.text.line_height_get(); }; + Uint64 now = SDL_GetTicks(); + fpsFrames++; + if (now - fpsSince >= SDL_MS_PER_SECOND) + { + fps = fpsFrames * (float)SDL_MS_PER_SECOND / (now - fpsSince); + fpsSince = now; + fpsFrames = 0; + } + if (game.settings.isFpsShown) + { + SDL_snprintf(text, sizeof(text), "%d FPS", (int)std::round(fps)); + meter_draw(text); + } if (game.settings.isDpsShown) { SDL_snprintf(text, sizeof(text), "%.1f DPS", game.effects.dps_get()); @@ -2055,9 +2245,7 @@ static void bars_arrange(CooldownBar* bars) // Keeps the HUD's timers and the cooldown bars up to date. void Ui::update(Game& game) { - isMenuSeen |= game.menu.isOpen; - if (isMenuSeen) - hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE); + hints_update(game); spectate_update(game); // The HUD's elements change while the mouse is over them, and change back after. @@ -2364,7 +2552,8 @@ void Ui::spectate_draw(Game& game) // A fighter's connection for its window, online (PING_NONE otherwise). static float rival_ping_get(Game& game, int fighter) { - return game.session.isActive ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE; + bool isShown = game.session.isActive && game.settings.isPingShown; + return isShown ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE; } // Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more diff --git a/src/ui.h b/src/ui.h index e61c762..bdb7d9b 100644 --- a/src/ui.h +++ b/src/ui.h @@ -198,6 +198,24 @@ struct Slot // How wide a fighter preview's column is (see Ui::fighter_preview_draw). constexpr float FIGHTER_PREVIEW_WIDTH = 120; +// The first-run hints along the bottom of the sandbox, each gone for good once its control has been used. Chat's shows +// only in a lobby. +enum Hint +{ + HINT_MOVE, + HINT_JUMP, + HINT_DOWN, + HINT_ATTACK, + HINT_CHARGE, + HINT_MENU, + HINT_RESET, + HINT_CHAT, + HINT_COUNT +}; + +// Each hint's name in settings.txt. +constexpr const char* HINT_KEYS[HINT_COUNT] = {"move", "jump", "down", "attack", "charge", "menu", "reset", "chat"}; + class Ui { public: @@ -292,7 +310,7 @@ private: bool isFocusing = false; bool isScrolled = false; char numberText[TOOLTIP_LENGTH] = {}; - float hintAlpha = 1; + float hintAlphas[HINT_COUNT] = {}; // The switching controls as they were last tick, to catch presses while spectating. bool spectateHeld[3] = {}; // How far the mouse being over each HUD element has changed it, from 0 to 1: hotbar slots (weapons, then trinkets) @@ -301,11 +319,16 @@ private: float slotHovers[ITEM_KIND_COUNT][SLOT_MAX] = {}; float rivalHovers[MAX_FIGHTERS] = {}; float statusHover = 0; + // Frames drawn since fpsSince (in milliseconds), and the rate they last came to. + float fps = 0; + Uint64 fpsSince = 0; + int fpsFrames = 0; float loadoutTop = 0; float widgetAlpha = 1; int tip = 0; + // The second of the countdown last beeped for (see countdown_beep). + int countdownBeeped = 0; bool isActiveSeen = false; - bool isMenuSeen = false; bool isTipChosen = false; bool isWidgetEnabled = true; bool isWidgetLive = false; @@ -317,6 +340,9 @@ private: void race_draw(Game& game); float results_draw(Game& game, float y); void countdown_aside_draw(Game& game); + void countdown_beep(Game& game); + void hints_update(Game& game); + void hints_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); diff --git a/src/weapon.cpp b/src/weapon.cpp index cb4f58a..dca43e0 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -1,7 +1,9 @@ #include "weapon.h" #include "sim.h" #include +#include #include +#include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; @@ -67,8 +69,21 @@ constexpr float UPPERCUT_REACH = 16; constexpr float VOLLEY_GAP = 6; constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; -constexpr float WHIP_COIL_GAP = 3; -constexpr int WHIP_COIL_STEPS = 48; +// A plain lash's motion, as shares of its lifetime: the handle draws back until WHIP_WINDUP, drives forward until +// WHIP_THROW and stops, the loop that sends off reaches the tip as it cracks at PATH_OUT, and from WHIP_SETTLE the +// hand draws it back in. Its heading is in radians off the aim, toward the side it rolls over. +constexpr int WHIP_LINKS = 16; +constexpr float WHIP_WINDUP = 0.12f; +constexpr float WHIP_THROW = 0.24f; +constexpr float WHIP_SETTLE = 0.6f; +constexpr float WHIP_RAISED = 1.6f; +constexpr float WHIP_REAR = 2.5f; +constexpr float WHIP_DROOP = -0.9f; +constexpr float WHIP_TAPER = 0.7f; +constexpr float WHIP_KICK = 2.6f; +constexpr float WHIP_WOBBLES = 1; +constexpr float WHIP_DAMPING = 0.02f; +constexpr float WHIP_SAG = 0.6f; constexpr float WHIP_PULL = 220; constexpr float WOBBLE_IMPULSE = 0.15f; // Vacuum: how fast its pull speeds fighters and shots up toward it, how much of an airblast's knock is left at the edge @@ -364,37 +379,101 @@ float strength_get(Attack& attack, Vec2 point) return attack.bonus * multiplier_get(attack, point); } +// A weapon's muzzle as weapons.anm2 places it: from the hand along the aim (x) and to its side (y, flipped facing +// left). +struct Muzzle +{ + const char* weapon; + float x; + float y; +}; + +// Each weapon's muzzle by its place in WEAPON_DEFS, from the Muzzle nulls in weapons.anm2 (read in at build time, see +// sim.cmake), or MUZZLE_DISTANCE straight ahead for a weapon without one. +static constexpr std::array MUZZLES = [] { + constexpr Muzzle NULLS[] = {WEAPON_MUZZLES{nullptr, 0, 0}}; + std::array muzzles; + for (int weapon = 0; weapon < WEAPON_COUNT; weapon++) + { + muzzles[weapon] = {MUZZLE_DISTANCE, 0}; + for (const Muzzle& null : NULLS) + if (null.weapon && std::string_view(null.weapon) == WEAPON_DEFS[weapon].name) + muzzles[weapon] = {null.x, null.y}; + } + return muzzles; +}(); + +// Where an attack's shots leave its weapon, held at its origin and pointing along its angle: the weapon's muzzle (see +// MUZZLES), turned to the angle and mirrored facing left. +Vec2 muzzle_get(Attack& attack) +{ + Vec2 muzzle = MUZZLES[attack.weapon]; + float side = attack.isLeft ? -1 : 1; + return attack.origin + vec2_polar(attack.angle, muzzle.x) + vec2_polar(attack.angle + PI / 2, muzzle.y * side); +} + // How much of an attack's black fill its colour takes over, from none at plain damage to all at FILL_STRENGTH. float fill_get(float strength) { return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); } -// 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. +// Where a plain lash's handle points at a share of its lifetime, off the aim (see WHIP_WINDUP): eased back slowly, +// driven forward sharply over the top, held along the aim through the crack, then drooping. +static float lash_heading_get(float time) +{ + time = std::max(0.0f, time); + if (time < WHIP_WINDUP) + { + float ease = 1 - time / WHIP_WINDUP; + return math_lerp(WHIP_RAISED, WHIP_REAR, 1 - ease * ease); + } + if (time < WHIP_THROW) + { + float drive = (time - WHIP_WINDUP) / (WHIP_THROW - WHIP_WINDUP); + return math_lerp(WHIP_REAR, 0, drive * drive * (3 - 2 * drive)); + } + if (time < PATH_OUT) + return 0; + return WHIP_DROOP * std::min(1.0f, (time - PATH_OUT) / (WHIP_SETTLE - PATH_OUT)); +} + +// The way a heading off a plain lash's aim points, turning toward the side it rolls over. +static Vec2 lash_direction_get(Attack& attack, float heading) +{ + Vec2 forward = vec2_polar(attack.aimAngle, 1); + Vec2 up = attack.isLeft ? Vec2{-forward.y, forward.x} : Vec2{forward.y, -forward.x}; + return forward * math_cos(heading) + up * math_sin(heading); +} + +// A plain lash as a chain of links from the end of its handle. Each link points where the handle pointed a moment +// before, longer toward the tip but less so for the lighter links, so the handle's stop rolls a loop down it that +// straightens the tip as it cracks. After, the tip snaps back over and wobbles out ever slower while the slack whip +// sags, and the hand draws it in. 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}; - 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 (coil < 1) - return points; + float time = attack.progress_get(); + float recoil = std::max(0.0f, (time - PATH_OUT) / (1 - PATH_OUT)); + float slack = time < PATH_OUT ? std::max(0.0f, 1 - time / WHIP_THROW) : std::min(1.0f, 2 * recoil); + float kick = time < PATH_OUT + ? 0 + : WHIP_KICK * math_sin(2 * PI * WHIP_WOBBLES * std::sqrt(recoil)) * math_pow(WHIP_DAMPING, recoil); + float drawn = time < WHIP_SETTLE ? 1 : 1 - (time - WHIP_SETTLE) / (1 - WHIP_SETTLE); + float step = attack.length * drawn / WHIP_LINKS; + Vec2 forward = vec2_polar(attack.aimAngle, 1); + Vec2 up = lash_direction_get(attack, PI / 2); + // Slack, it sags toward straight down: behind the hand while drawn back, ahead of it after the crack. + float rest = time < PATH_OUT ? WHIP_REAR : 0; + float down = rest + std::remainder(math_atan2(up.y, forward.y) - rest, 2 * PI); - // 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++) + std::vector points = {attack.position}; + for (int i = 0; i < WHIP_LINKS; i++) { - heading += turn * step / std::sqrt(2 * spacing * (coil - i * step)); - point = point + vec2_polar(heading, step); - points.push_back(point); + float along = (i + 0.5f) / WHIP_LINKS; + float heading = lash_heading_get(time - (PATH_OUT - WHIP_THROW) * math_pow(along, WHIP_TAPER)); + heading += kick * along * along; + heading += (down - heading) * WHIP_SAG * slack * along; + points.push_back(points.back() + lash_direction_get(attack, heading) * step); } return points; } @@ -465,7 +544,7 @@ static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) } // The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the -// hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip. +// hand, out as far ahead as its lash reaches 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.is_charged()) @@ -476,7 +555,10 @@ int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; - float front = vec2_length(attack.tip - attack.origin); + float front = 0; + Vec2 forward = vec2_polar(attack.aimAngle, 1); + for (Vec2 point : lash_get(attack)) + front = std::max(front, vec2_dot(point - attack.origin, forward)); for (int ray = 0; ray < WHIP_RAYS; ray++) { float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1); @@ -748,7 +830,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) .isLeft = fighter.is_left(), .isSecondary = shot.isSecondary, }; - attack.position = attack.origin + aim * MUZZLE_DISTANCE; + attack.position = muzzle_get(attack); // Charging burns a grenade's fuse down (fully charged, it goes off in the barrel, see below). if (kind == ATTACK_GRENADE) @@ -808,8 +890,8 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) piece.aimAngle += offset; piece.velocity = vec2_polar(piece.angle, stats.speed + push); // 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); + float slash = SLASH_DISTANCE * piece.scale + SLASH_GAP; + piece.position = kind == ATTACK_SLASH ? piece.origin + vec2_polar(piece.angle, slash) : muzzle_get(piece); piece.isSecondary |= split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2; if (def.isOrbiting) @@ -843,10 +925,9 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) } sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); - Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE; float side = fighter.is_left() ? -1 : 1; if (weapon.flash > 0) - sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index); + sim.cues->flash_spawn(attack, weapon.flash); if (weapon.isEjecting) sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); @@ -1077,10 +1158,8 @@ 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 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. + // once. A plain lash goes with the hand, as long as its reach along the aim (short of any wall), its handle + // and links moving as lash_get says. It hits anywhere in its cone as far as it reaches until it cracks. if (attack.is_charged()) { attack.origin = fighter.hand_get(attack.hand); @@ -1092,15 +1171,12 @@ void Weapons::attack_move(Sim& sim, Attack& attack) auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; - 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); + Vec2 reach = ray_march(attack.origin, attack.aimAngle, reach_at_get(attack), isBlocked); + attack.angle = vec2_angle(lash_direction_get(attack, lash_heading_get(progress))); 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, std::clamp(out - WHIP_HANDLE, 0.0f, attack.length)); - attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0; + attack.length = std::max(0.0f, vec2_length(reach - attack.origin) - WHIP_HANDLE); + attack.tip = lash_get(attack).back(); + attack.hitsLeft = progress < PATH_OUT ? attack.hitsLeft : 0; break; } case ATTACK_SPIN: @@ -1543,6 +1619,8 @@ void Weapons::damage_deal(Sim& sim, Damage damage) float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; attacker.hitstop_add(damage.hitstop); damage.knock *= attacker.stat_get(STAT_KNOCKBACK); + bool isCounted = damage.target != damage.owner && !(damage.target < MAX_FIGHTERS && sim.match.is_racing()); + attacker.damageDealt += isCounted ? damage.amount : 0; if (damage.target < MAX_FIGHTERS) sim.fighters[damage.target].damage_take(sim, damage); else @@ -1829,7 +1907,11 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); 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; + Attack probe = {.origin = fighter.hand_get(), + .angle = vec2_angle(aim), + .weapon = fighter.weaponLoadout[key], + .isLeft = fighter.is_left()}; + Vec2 point = muzzle_get(probe); int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; diff --git a/src/weapon.h b/src/weapon.h index 2340856..6d8b7f2 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -388,14 +388,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .spread = 5}, .levels = { - {.normal = {.damage = 14, .lifetime = 0.12f, .speed = 283}, - .charged = {.damage = 50, .lifetime = 0.35f, .lunge = 420, .scale = 1}, + {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 283}, + .charged = {.damage = 58, .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}, + {.normal = {.damage = 18, .lifetime = 0.12f, .speed = 333}, + .charged = {.damage = 80, .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}, + {.normal = {.damage = 22, .lifetime = 0.12f, .speed = 383}, + .charged = {.damage = 102, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f}, .cooldown = 0.045f}, }, .bot = {.rangeMax = 36}, @@ -431,14 +431,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, + .charged = {.damage = 34, .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}, + {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, + .charged = {.damage = 45, .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}, + {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, + .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, .cooldown = 0.3f}, }, .bot = {.rangeMax = 48}, @@ -475,14 +475,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 19, .lifetime = 0.3f, .range = 21.43f, .scale = 1.07f, .speed = 258}, + .charged = {.damage = 38, .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}, + {.normal = {.damage = 27, .lifetime = 0.3f, .range = 24.29f, .scale = 1.21f, .speed = 292}, + .charged = {.damage = 53, .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}, + {.normal = {.damage = 32, .lifetime = 0.3f, .range = 27.14f, .scale = 1.36f, .speed = 327}, + .charged = {.damage = 66, .lifetime = 0.45f, .range = 57.14f, .scale = 2.86f, .speed = 459}, .cooldown = 0.5f}, }, .bot = {.rangeMax = 36}, @@ -514,15 +514,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 10, .lifetime = 0.29f, .speed = 890}, + .charged = {.damage = 28, .lifetime = 0.29f, .lunge = 200, .speed = 890}, + .cooldown = 0.55f}, + {.normal = {.damage = 14, .lifetime = 0.31f, .speed = 970}, + .charged = {.damage = 37, .lifetime = 0.31f, .lunge = 250, .speed = 970}, + .cooldown = 0.55f}, + {.normal = {.damage = 17, .lifetime = 0.33f, .speed = 1040}, + .charged = {.damage = 46, .lifetime = 0.33f, .lunge = 300, .speed = 1040}, + .cooldown = 0.55f}, }, .bot = {.rangeMin = 40, .rangeMax = 140}, }, @@ -556,15 +556,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 950}, + .charged = {.area = 6, .damage = 46, .range = 125}, + .cooldown = 0.4f}, + {.normal = {.damage = 28, .lifetime = 0.38f, .speed = 1175}, + .charged = {.area = 7, .damage = 70, .range = 155}, + .cooldown = 0.4f}, + {.normal = {.damage = 32, .lifetime = 0.38f, .speed = 1400}, + .charged = {.area = 8, .damage = 93, .range = 185}, + .cooldown = 0.4f}, }, .bot = {.rangeMin = 90, .rangeMax = 160}, }, @@ -605,14 +605,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 11, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, + .charged = {.damage = 35, .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}, + {.normal = {.damage = 15, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, + .charged = {.damage = 44, .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}, + {.normal = {.damage = 19, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, + .charged = {.damage = 51, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, .cooldown = 0.5f}, }, .bot = {.rangeMax = 60}, @@ -655,16 +655,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, - .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .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}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .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}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .cooldown = 0.2f, .count = 3}, }, @@ -708,17 +708,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 16, .range = 155, .speed = 600}, - .charged = {.damage = 32, .lifetime = 0.1f, .range = 250, .speed = 400}, - .cooldown = 0.85f, + {.normal = {.damage = 19, .range = 175, .speed = 600}, + .charged = {.damage = 35, .lifetime = 0.1f, .range = 250, .speed = 400}, + .cooldown = 0.7f, .reel = 120}, - {.normal = {.damage = 22, .range = 155, .speed = 700}, - .charged = {.damage = 45, .lifetime = 0.1f, .range = 300, .speed = 450}, - .cooldown = 0.85f, + {.normal = {.damage = 25, .range = 175, .speed = 700}, + .charged = {.damage = 48, .lifetime = 0.1f, .range = 300, .speed = 450}, + .cooldown = 0.7f, .reel = 135}, - {.normal = {.damage = 28, .range = 155, .speed = 800}, - .charged = {.damage = 58, .lifetime = 0.1f, .range = 350, .speed = 500}, - .cooldown = 0.85f, + {.normal = {.damage = 31, .range = 175, .speed = 800}, + .charged = {.damage = 61, .lifetime = 0.1f, .range = 350, .speed = 500}, + .cooldown = 0.7f, .reel = 150}, }, .bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD}, @@ -763,14 +763,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isShot = true}, .levels = { - {.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, - .charged = {.damage = 22, .range = 500, .speed = 560, .volley = 1}, + {.normal = {.damage = 21, .range = 360, .speed = 320, .volley = 1}, + .charged = {.damage = 21, .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}, + {.normal = {.damage = 21, .range = 360, .speed = 360, .volley = 2}, + .charged = {.damage = 29, .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}, + {.normal = {.damage = 21, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3}, + .charged = {.damage = 36, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3}, .cooldown = 0.6f}, }, .bot = {.rangeMin = 100, .rangeMax = 260, .charge = CHARGE_ALWAYS, .aim = AIM_LEAD}, @@ -815,11 +815,11 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { {.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}, + {.normal = {.damage = 14, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 25, .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}, + {.normal = {.damage = 17, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 37, .lunge = -120, .range = RANGE_INFINITE}, .cooldown = 0.2f}, }, .bot = {.rangeMin = 80, .rangeMax = 320, .aim = AIM_LEAD}, @@ -862,8 +862,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isShot = true}, .levels = { - {.normal = {.damage = 2, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 4, + {.normal = {.damage = 4.5f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 4.5f, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -871,8 +871,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .speed = 1200, .volley = 5}, .cooldown = 0.12f}, - {.normal = {.damage = 4, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 8, + {.normal = {.damage = 6.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 9, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -880,8 +880,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .speed = 1200, .volley = 7}, .cooldown = 0.075f}, - {.normal = {.damage = 6, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 12, + {.normal = {.damage = 8.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 14, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -934,15 +934,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 5.75f, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5}, + .charged = {.damage = 26, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, + {.normal = {.damage = 7.5f, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6}, + .charged = {.damage = 35, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, + {.normal = {.damage = 9.5f, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7}, + .charged = {.damage = 43, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, }, .bot = {.rangeMax = 90, .aim = AIM_LEAD}, .isEjecting = true, @@ -989,15 +989,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 7.5f, .range = 660, .speed = 260}, + .charged = {.damage = 19, .range = 660, .speed = 220}, + .cooldown = 0.65f}, + {.normal = {.damage = 9.25f, .range = 840, .speed = 260}, + .charged = {.damage = 24, .range = 840, .speed = 220}, + .cooldown = 0.65f}, + {.normal = {.damage = 12, .range = 1020, .speed = 260}, + .charged = {.damage = 31, .range = 1020, .speed = 220}, + .cooldown = 0.65f}, }, .bot = {.rangeMin = 30, .rangeMax = 200, .aim = AIM_FEET}, }, @@ -1042,14 +1042,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.area = 28, .damage = 13, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 46, .damage = 27, .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}, + {.normal = {.area = 35, .damage = 17, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 58, .damage = 36, .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}, + {.normal = {.area = 42, .damage = 20, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 69, .damage = 47, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .cooldown = 0.9f}, }, .bot = {.rangeMin = 80, .rangeMax = 220, .aim = AIM_GROUND}, @@ -1103,15 +1103,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.area = 32, .damage = 34, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 32, .damage = 34, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, + {.normal = {.area = 38, .damage = 43, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 38, .damage = 43, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, + {.normal = {.area = 44, .damage = 52, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 44, .damage = 52, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, }, .bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_PLANNED, .aim = AIM_LEAD}, .isChargeScaled = true, @@ -1139,16 +1139,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isDrifting = true}, .levels = { - {.normal = {.area = 56, .damage = 55, .range = 120}, - .charged = {.area = 56, .damage = 55, .lifetime = 15, .range = 120, .speed = 720}, + {.normal = {.area = 56, .damage = 56, .range = 120}, + .charged = {.area = 56, .damage = 56, .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}, + {.normal = {.area = 66, .damage = 78, .range = 120}, + .charged = {.area = 66, .damage = 78, .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}, + {.normal = {.area = 76, .damage = 101, .range = 120}, + .charged = {.area = 76, .damage = 101, .lifetime = 15, .range = 120, .speed = 720}, .cooldown = 0.3f, .count = 6}, }, @@ -1192,14 +1192,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + {.normal = {.damage = 1.75f, .lifetime = 1.5f, .range = 115, .speed = 500}, + .charged = {.area = 40, .damage = 31, .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}, + {.normal = {.damage = 2.5f, .lifetime = 2.25f, .range = 140, .speed = 650}, + .charged = {.area = 55, .damage = 43, .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}, + {.normal = {.damage = 3.25f, .lifetime = 3, .range = 165, .speed = 800}, + .charged = {.area = 70, .damage = 56, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, .cooldown = 0.08f}, }, .bot = {.rangeMax = 110}, @@ -1212,18 +1212,36 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .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}, + .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}, + {.normal = {.damage = 1.75f, .range = 110, .speed = 160}, + .charged = {.damage = 7.25f, .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}, + {.normal = {.damage = 2.75f, .range = 135, .speed = 190}, + .charged = {.damage = 11, .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}, + {.normal = {.damage = 4.75f, .range = 160, .speed = 220}, + .charged = {.damage = 17, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100}, .cooldown = 0.2f}, }, .bot = {.rangeMax = 110, .charge = CHARGE_ALWAYS}, @@ -1262,17 +1280,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.15f}, .levels = { - {.normal = {.damage = 12, .range = 320, .speed = 248, .volley = 1}, - .charged = {.area = 30, .damage = 20, .range = 220}, - .cooldown = 0.7f, + {.normal = {.damage = 14, .range = 320, .speed = 248, .volley = 1}, + .charged = {.area = 30, .damage = 27, .range = 220}, + .cooldown = 0.6f, .limit = 3}, - {.normal = {.damage = 14, .range = 320, .speed = 263, .volley = 2}, - .charged = {.area = 40, .damage = 30, .range = 250}, - .cooldown = 0.7f, + {.normal = {.damage = 16, .range = 320, .speed = 263, .volley = 2}, + .charged = {.area = 40, .damage = 39, .range = 250}, + .cooldown = 0.6f, .limit = 6}, - {.normal = {.damage = 15, .range = 320, .speed = 278, .volley = 3}, - .charged = {.area = 50, .damage = 40, .range = 280}, - .cooldown = 0.7f, + {.normal = {.damage = 18, .range = 320, .speed = 278, .volley = 3}, + .charged = {.area = 50, .damage = 50, .range = 280}, + .cooldown = 0.6f, .limit = 9}, }, .bot = {.rangeMin = 60, .rangeMax = 260}, @@ -1432,6 +1450,7 @@ float blast_area_get(Attack& attack); float strength_get(Attack& attack, Vec2 point); float fill_get(float strength); std::vector lash_get(Attack& attack); +Vec2 muzzle_get(Attack& attack); int shapes_get(Sim& sim, Attack& attack, Capsule* shapes); // One hit's worth of damage and the feedback that goes with it. Source is where it came from, which a fighter gets