Profile stats, first-run hints, tiered balance, a wave-driven whip, and more options

- Profile Stats: damage dealt, kills and wins (single player and multiplayer) and the most and least played weapons
  and trinkets, counted from running totals in the simulation so online rewinds correct them. Protocol 6.
- First-run control hints in the sandbox (and Chat in lobbies), each gone for good once used.
- The ready countdown and a timed round's last five seconds beep (new countdown sound), and those seconds show big.
  When time runs out, everyone short of the most health dies.
- Weapon balance tuned into tiers: Whip and Grenade Launcher on top, then melee, ranged, and utility (Vacuum,
  Grapple, Uzi) at the bottom.
- The whip's lash is a chain of links a wave rolls down: windup, loop, crack, then a floppy recoil.
- Muzzles come from Muzzle nulls in weapons.anm2 (read in at build time), for shots and the flash, which now follows
  the weapon.
- Settings: Quit, Scale (1x-4x or Free), FPS meter and limit beside Vsync, Mute on Unfocus, Damage Numbers, Rumble,
  Stick Deadzone, and an Online section (Host, Chat, Ping) shared with the Multiplayer page.
- The menu header lays out tabs and buttons by their names. About is sandbox-only. The score no longer shifts with
  the menu's tab.
- Smaller meters, 90 tips (all ending in !), and a shorter lobby welcome.
This commit is contained in:
shweet committed 2026-11-10 18:32:31 -05:00
1 parent e9d8cac926
commit b96defa1e6
32 files changed
+1368 -396

No files matched your search

+9 -6
View File
@@ -46,6 +46,9 @@
"layers": [ "layers": [
{"name": "sprite"} {"name": "sprite"}
], ],
"nulls": [
{"name": "Muzzle"}
],
"defaultAnimation": "Fist", "defaultAnimation": "Fist",
"animations": { "animations": {
"Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, "Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]},
@@ -62,12 +65,12 @@
"Bow": {"layers": [{"layer": 0, "frames": [{"region": 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 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}]}]}, "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}]}]}, "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 20}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -3.5]}]}]},
"Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}], "nulls": [{"null": 0, "frames": [{"pos": [14, -2.5]}]}]},
"Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, "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}]}]}, "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}]}]}, "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 29}]}], "nulls": [{"null": 0, "frames": [{"pos": [21, 0]}]}]},
"Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -0.5]}]}]},
"Mine": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, "Mine": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]},
"Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]},
"Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, "Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]},
+3 -2
View File
@@ -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 | | Sound | Source | From | Speed | Rate | BRR bytes |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| beam | SpaceZap 4001_88_7 | 1.09s | 1x | 11025 | 2727 | | 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 | | blast | synth pew | | 1x | 11025 | 1053 |
| bolter | US03-11-1 ELECTRICITY - SPARK GAP CRACKLE AND HUM | 7.90s | 1x | 11025 | 1791 | | 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 | | 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 | | cast | synth ember | | 1x | 11025 | 2556 |
| charge | synth warp | | 1x | 11025 | 5076 | | charge | synth warp | | 1x | 11025 | 5076 |
| chat | synth beeps | | 1x | 11025 | 576 | | 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 | | | | | | crawl | kept from the synth set | | | | |
| dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 | | dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 |
| death | US03-34-1 KICK, HUMAN - SINGLE KICK INTO STOMACH | 0.17s | 1x | 11025 | 2916 | | death | 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 | | | | | | whip_heavy | kept from the demo1 (its whip) set | | | | |
| wisp | US02-45-5 ARCHERY - SHOOT FLAMING ARROW | 0.18s | 1x | 11025 | 1584 | | 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.
Binary file not shown.
+1
View File
@@ -23,6 +23,7 @@ constexpr const char* SOUND_NAMES[SOUND_COUNT] = {
"cast", "cast",
"charge", "charge",
"chat", "chat",
"countdown",
"crawl", "crawl",
"dash", "dash",
"death", "death",
+1
View File
@@ -24,6 +24,7 @@ enum SoundId
SOUND_CAST, SOUND_CAST,
SOUND_CHARGE, SOUND_CHARGE,
SOUND_CHAT, SOUND_CHAT,
SOUND_COUNTDOWN,
SOUND_CRAWL, SOUND_CRAWL,
SOUND_DASH, SOUND_DASH,
SOUND_DEATH, SOUND_DEATH,
+4 -1
View File
@@ -136,6 +136,10 @@ void Chat::say(Game& game, int member, const std::string& text)
return; return;
times.push_back(now); 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; bool isOnline = game.session.isActive;
int fighter = isOnline ? game.session.fighter_get(game, member) : game.local; int fighter = isOnline ? game.session.fighter_get(game, member) : game.local;
Color color = fighter >= 0 ? game.color_get(fighter, TINT_PLAIN) : WHITE; 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) if ((int)lines.size() > CHAT_LINES_MAX)
lines.erase(lines.begin()); lines.erase(lines.begin());
bubbles[member].push_back({.text = text, .time = now}); bubbles[member].push_back({.text = text, .time = now});
typing[member] = false;
game.audio.play(SOUND_CHAT); game.audio.play(SOUND_CHAT);
} }
+4 -2
View File
@@ -116,9 +116,10 @@ void Effects::popup_spawn(Vec2 position, const char* text, Color color)
effect_add(popups, popup); 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 // 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. // Each flash is a long streak along the shot crossed by a short one, shrinking as it fades.
for (Flash& flash : flashes) for (Flash& flash : flashes)
{ {
game.scene.flash_follow(flash);
float fade = flash.life / FLASH_LIFE; float fade = flash.life / FLASH_LIFE;
float length = FLASH_LENGTH * flash.size * fade; float length = FLASH_LENGTH * flash.size * fade;
float width = FLASH_WIDTH * flash.size * fade; float width = FLASH_WIDTH * flash.size * fade;
+5 -1
View File
@@ -49,6 +49,10 @@ struct Flash
float angle = 0; float angle = 0;
float life = 0; float life = 0;
float size = 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, // 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: public:
void burst(Burst burst, Vec2 position, float angle, Color color); void burst(Burst burst, Vec2 position, float angle, Color color);
void afterimage_spawn(Pose pose, int worn); 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 afterimage_draw(Game& game);
void number_spawn(Vec2 position, int value, int target, Color color); void number_spawn(Vec2 position, int value, int target, Color color);
void dps_record(int amount); void dps_record(int amount);
+8
View File
@@ -145,7 +145,15 @@ void Fighter::damage_take(Sim& sim, Damage damage)
knock_take(direction * knock, damage.owner); knock_take(direction * knock, damage.owner);
if (health > 0) if (health > 0)
return; 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; isAlive = false;
charge_cancel(sim); charge_cancel(sim);
for (Attack& attack : sim.weapons.attacks) for (Attack& attack : sim.weapons.attacks)
+6
View File
@@ -123,6 +123,11 @@ public:
float scale = 1; float scale = 1;
float shockCooldown = 0; float shockCooldown = 0;
float contactCooldown = 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 index = 0;
int launcher = 0; int launcher = 0;
int slot = 0; int slot = 0;
@@ -152,6 +157,7 @@ public:
void position_set(Sim& sim, Vec2 feet); void position_set(Sim& sim, Vec2 feet);
void slots_set(int weapons, int trinkets); void slots_set(int weapons, int trinkets);
void damage_take(Sim& sim, Damage damage); void damage_take(Sim& sim, Damage damage);
void die(Sim& sim);
void update(Sim& sim); void update(Sim& sim);
void blend(float alpha); void blend(float alpha);
void impulse_add(Vec2 impulse); void impulse_add(Vec2 impulse);
+2
View File
@@ -14,6 +14,7 @@
#include "settings.h" #include "settings.h"
#include "sim.h" #include "sim.h"
#include "sprites.h" #include "sprites.h"
#include "stats.h"
#include "text.h" #include "text.h"
#include "ui.h" #include "ui.h"
@@ -48,6 +49,7 @@ struct Game
Session session; Session session;
Sim sim; Sim sim;
Sprites sprites; Sprites sprites;
Stats stats;
Text text; Text text;
Ui ui; Ui ui;
Vec2 camera; Vec2 camera;
+14 -2
View File
@@ -11,7 +11,6 @@ constexpr int BIND_WHEEL_DOWN = BIND_WHEEL_UP + 1;
constexpr int BIND_AXIS = 100; constexpr int BIND_AXIS = 100;
constexpr float AXIS_MAX = 32767; constexpr float AXIS_MAX = 32767;
constexpr float AXIS_THRESHOLD = 0.5f; constexpr float AXIS_THRESHOLD = 0.5f;
constexpr float AIM_DEADZONE = 0.3f;
constexpr float AIM_REACH = 120; constexpr float AIM_REACH = 120;
constexpr float NAV_THRESHOLD = 0.6f; constexpr float NAV_THRESHOLD = 0.6f;
constexpr Uint64 NAV_DELAY = 400; constexpr Uint64 NAV_DELAY = 400;
@@ -308,7 +307,7 @@ void Input::poll(Game& game)
if (!device.pad) if (!device.pad)
continue; continue;
Vec2 axes = {device.axes[SDL_GAMEPAD_AXIS_RIGHTX], device.axes[SDL_GAMEPAD_AXIS_RIGHTY]}; 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; continue;
Device& aimer = device.isClaimed ? device : devices[DEVICE_KEYBOARD]; Device& aimer = device.isClaimed ? device : devices[DEVICE_KEYBOARD];
aimer.stick = vec2_normalize(axes); 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; 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 // A control's binding as shown in hints: its gamepad side while the keyboard's device aims with a stick, otherwise its
// keyboard side. // keyboard side.
void Input::control_name_get(Control control, char* text, int size) void Input::control_name_get(Control control, char* text, int size)
+3
View File
@@ -149,6 +149,8 @@ class Input
public: public:
Binding bindings[CONTROL_COUNT]; Binding bindings[CONTROL_COUNT];
Device devices[DEVICE_COUNT]; Device devices[DEVICE_COUNT];
// How far a right stick must lean before it aims (see Settings::stickDeadzone).
float aimDeadzone = 0.3f;
Vec2 mouse; Vec2 mouse;
char typed[TYPED_LENGTH] = {}; char typed[TYPED_LENGTH] = {};
float scroll = 0; float scroll = 0;
@@ -190,6 +192,7 @@ public:
bool is_mouse_released(MouseButton button); bool is_mouse_released(MouseButton button);
void binding_name_get(int binding, BindingSide side, char* text, int size); void binding_name_get(int binding, BindingSide side, char* text, int size);
void control_name_get(Control control, char* text, int size); 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); bool is_control_down(int device, Control control);
+36 -1
View File
@@ -6,6 +6,8 @@
#include <SDL3/SDL_main.h> #include <SDL3/SDL_main.h>
#include <algorithm> #include <algorithm>
// 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 int WINDOW_SCALE = 2;
constexpr double MAX_FRAME_TIME = 0.25; constexpr double MAX_FRAME_TIME = 0.25;
#ifdef SDL_PLATFORM_EMSCRIPTEN #ifdef SDL_PLATFORM_EMSCRIPTEN
@@ -69,6 +71,7 @@ void game_tick(Game& game)
static void presentation_update(Game& game) static void presentation_update(Game& game)
{ {
game.effects.update(); game.effects.update();
game.stats.update(game);
game.ui.update(game); game.ui.update(game);
game.scene.update(); game.scene.update();
game.cameraPrevious = game.camera; game.cameraPrevious = game.camera;
@@ -122,6 +125,8 @@ struct FrameClock
static Game game; static Game game;
static FrameClock frameClock; 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. // One frame's input and as many fixed ticks as the time since the last frame allows.
static void frame_step() static void frame_step()
@@ -169,6 +174,28 @@ static void frame_step()
game.alpha = frameClock.accumulator / TICK; 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. // One frame, stepped and drawn. Returns whether to keep going.
static bool frame_run() static bool frame_run()
{ {
@@ -178,8 +205,13 @@ static bool frame_run()
} }
#ifdef SDL_PLATFORM_EMSCRIPTEN #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() static void frame_callback()
{ {
if (frame_wait_get() > FRAME_SLACK)
return;
frame_due_advance();
frame_run(); frame_run();
} }
@@ -276,9 +308,12 @@ int main(int argc, char** argv)
}); });
return 0; return 0;
#endif #endif
while (frame_run()) do
{ {
SDL_DelayPrecise(frame_wait_get());
frame_due_advance();
} }
while (frame_run());
game.settings.save(game); game.settings.save(game);
game.net.shutdown(); game.net.shutdown();
+5 -1
View File
@@ -187,6 +187,7 @@ void Match::update(Sim& sim)
if (winner >= 0 && wins[winner] >= rules.wins_get()) if (winner >= 0 && wins[winner] >= rules.wins_get())
{ {
state = MATCH_OVER; state = MATCH_OVER;
sim.fighters[winner].wins++;
std::fill(std::begin(isReady), std::end(isReady), false); std::fill(std::begin(isReady), std::end(isReady), false);
break; 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, // 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) void Match::deathmatch_update(Sim& sim)
{ {
int alive = 0; int alive = 0;
@@ -519,6 +520,9 @@ void Match::deathmatch_update(Sim& sim)
winner = fighter.health == best ? -1 : i; winner = fighter.health == best ? -1 : i;
best = fighter.health; 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) if (winner >= 0)
wins[winner]++; wins[winner]++;
+146 -89
View File
@@ -24,7 +24,9 @@ constexpr float DIM_ALPHA = 0.6f;
constexpr float DIVIDER = 2; constexpr float DIVIDER = 2;
constexpr float STICK_DEADZONE = 0.3f; constexpr float STICK_DEADZONE = 0.3f;
constexpr float STICK_SCROLL = 240; 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 LABEL_WIDTH = 136;
constexpr float NUMBER_WIDTH = 84; constexpr float NUMBER_WIDTH = 84;
constexpr float PADDING = 8; 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"}; 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. // 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 // 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 // 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; isForfeitArmed &= isOpen;
isQuitArmed &= isOpen;
int tabs[TAB_COUNT]; int tabs[TAB_COUNT];
int shown = tabs_get(game, tabs); int shown = tabs_get(game, tabs);
if (std::find(tabs, tabs + shown, tab) == tabs + shown) if (std::find(tabs, tabs + shown, tab) == tabs + shown)
@@ -120,13 +118,14 @@ void Menu::update(Game& game)
Vec2 mouse = input.mouse; Vec2 mouse = input.mouse;
bool isLeft = input.is_mouse_pressed(MOUSE_LEFT); bool isLeft = input.is_mouse_pressed(MOUSE_LEFT);
bool isRight = input.is_mouse_pressed(MOUSE_RIGHT); bool isRight = input.is_mouse_pressed(MOUSE_RIGHT);
Rect strip = tab_area_get(game, layout.header); HeaderLayout header = header_layout_get(game, layout.header);
if (isLeft && is_point_in_rect(mouse, strip)) 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 (!is_point_in_rect(mouse, header.tabCells[i]))
if (clicked != tab) continue;
if (header.tabs[i] != tab)
game.audio.play(SOUND_SELECT); game.audio.play(SOUND_SELECT);
tab = clicked; tab = header.tabs[i];
} }
if (tab >= ITEM_TABS) if (tab >= ITEM_TABS)
return; 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 // Lays the header out: the tabs shown, then in a match its buttons (Ready while a round counts down, and Forfeit, or
// leaving, with a Ready button beside it while a round counts down. Spectating, it's a button to stop. // 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) void Menu::header_draw(Game& game, MenuLayout& layout)
{ {
Renderer& renderer = game.renderer; Renderer& renderer = game.renderer;
Text& text = game.text; Text& text = game.text;
Ui& ui = game.ui; Ui& ui = game.ui;
Rect strip = tab_area_get(game, layout.header); HeaderLayout header = header_layout_get(game, layout.header);
int tabs[TAB_COUNT]; float scale = header.scale;
int shown = tabs_get(game, tabs); for (int i = 0; i < header.tabCount; i++)
float width = 0;
for (int i = 0; i < shown; i++)
width += text.size_get(TAB_NAMES[tabs[i]], FONT_SMALL, TAB_SCALE).x + PADDING * 2;
float scale = width <= strip.w ? TAB_SCALE : 1;
for (int i = 0; i < shown; i++)
{ {
// Another tab under the mouse gets a faint box behind its name. // 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}; Rect cell = header.tabCells[i];
bool isHovered = tabs[i] != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); int shown = header.tabs[i];
bool isHovered = shown != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell);
if (isHovered) if (isHovered)
renderer.rect_draw({cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}, renderer.rect_draw({cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING},
color_fade(ui.shade, TAB_HOVER_ALPHA)); color_fade(ui.shade, TAB_HOVER_ALPHA));
const char* name = TAB_NAMES[tabs[i]]; Vec2 size = text.size_get(TAB_NAMES[shown], FONT_SMALL, scale);
Vec2 size = text.size_get(name, FONT_SMALL, scale); Vec2 corner = {std::round(cell.x + (cell.w - size.x) / 2), std::round(cell.y + (cell.h - size.y) / 2)};
float center = strip.x + strip.w * (i * 2 + 1) / (shown * 2);
Vec2 corner = {std::round(center - size.x / 2), std::round(strip.y + (strip.h - size.y) / 2)};
text.draw( text.draw(
renderer, name, renderer, TAB_NAMES[shown],
{.color = tabs[i] == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); {.color = shown == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}});
if (i > 0) if (i > 0)
renderer.rect_draw({std::round(strip.x + strip.w * i / shown - DIVIDER / 2), strip.y + PADDING, DIVIDER, renderer.rect_draw({cell.x - DIVIDER / 2, cell.y + PADDING, DIVIDER, cell.h - PADDING * 2}, ui.tint);
strip.h - PADDING * 2},
ui.tint);
} }
Match& match = game.sim.match; Match& match = game.sim.match;
if (match.state == MATCH_PRACTICE) for (int i = 0; i < header.buttonCount; i++)
return;
Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING};
if (game.session.is_watching(game))
{ {
button.w += HEADER_BUTTON_WIDTH; Rect cell = header.buttonCells[i];
if (ui.button_draw(game, button, "Stop Spectating")) 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); case BUTTON_STOP:
isOpen = false; 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 // 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()) else if (!net.error.empty())
status_draw(net.error.c_str(), true); 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"); ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Online");
y += ROW_HEIGHT; y += ROW_HEIGHT;
ui.hint_set(game, {area.x, y, area.w, WIDGET_HEIGHT}, "The server hosting lobbies.\nEmpty it to reset."); game.settings.online_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, LABEL_WIDTH);
ui.label_draw(game, "Host", {area.x, y});
game.settings.host_draw(game, {area.x + LABEL_WIDTH, y, area.w - LABEL_WIDTH, WIDGET_HEIGHT});
return; return;
} }
@@ -855,7 +902,8 @@ void Menu::online_draw(Game& game, Rect area)
bool isSelf = member.id == net.selfId; bool isSelf = member.id == net.selfId;
std::string name = session.isActive ? session.name_get(game, member.id) : member.name; 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]" : ""; 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); SDL_snprintf(line, sizeof(line), "- %s%s", name.c_str(), tags);
while (text.size_get(line).x > room && !name.empty()) 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); 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)}; 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}); 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.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); 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 // 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 // (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) int Menu::tabs_get(Game& game, int* tabs)
{ {
Match& match = game.sim.match; Match& match = game.sim.match;
@@ -1238,7 +1286,7 @@ int Menu::tabs_get(Game& game, int* tabs)
tabs[count++] = i; tabs[count++] = i;
return count; 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; tabs[count++] = tab;
return count; return count;
} }
@@ -1253,33 +1301,42 @@ bool Menu::is_choosable(Game& game, ItemKind kind)
return match.is_picking(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 // The size a hotbar of count slots takes in the HUD: the largest that keeps it within its column.
// its column. On an item tab, both sit under the menu either side of its middle, full size. static IconSize hud_size_get(int count)
Rect Menu::slot_rect(ItemKind kind, int index, int count)
{ {
IconSize size = slot_size_get(count);
float slot = SLOT_SIZES[size];
float step = slot + SLOT_GAPS[size];
Vec2 position = {HOTBAR_MARGIN + index * step,
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++) for (int size = ICON_FULL; size < ICON_TINY; size++)
if (count * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size] <= HUD_COLUMN_WIDTH) if (count * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size] <= HUD_COLUMN_WIDTH)
return (IconSize)size; return (IconSize)size;
return ICON_TINY; 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 // 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. // and as many grid columns as fit.
MenuLayout Menu::layout_get() MenuLayout Menu::layout_get()
+25
View File
@@ -53,6 +53,28 @@ struct MenuLayout
int rows = 1; 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, // 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 // 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 // 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); void target_fill(Game& game);
bool is_bars_open(); bool is_bars_open();
Rect slot_rect(ItemKind kind, int index, int count); 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); IconSize slot_size_get(int count);
bool is_choosable(Game& game, ItemKind kind); bool is_choosable(Game& game, ItemKind kind);
int target_get(Game& game, ItemKind kind); int target_get(Game& game, ItemKind kind);
@@ -101,6 +124,7 @@ private:
int selections[ITEM_TABS] = {}; int selections[ITEM_TABS] = {};
int page = PAGE_MAIN; int page = PAGE_MAIN;
bool isForfeitArmed = false; bool isForfeitArmed = false;
bool isQuitArmed = false;
bool isMultiplayer = 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). // 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); Rules queueRules = rules_get(MODE_DEATHMATCH);
@@ -113,6 +137,7 @@ private:
MenuLayout layout_get(); MenuLayout layout_get();
Rect box_rect(int index); Rect box_rect(int index);
int tabs_get(Game& game, int* tabs); int tabs_get(Game& game, int* tabs);
HeaderLayout header_layout_get(Game& game, Rect header);
int item_count(); int item_count();
int row_count(); int row_count();
void slot_assign(Game& game, int slot); void slot_assign(Game& game, int slot);
+1 -1
View File
@@ -27,7 +27,7 @@
#include <string_view> #include <string_view>
#include <type_traits> #include <type_traits>
constexpr int PROTOCOL_VERSION = 5; constexpr int PROTOCOL_VERSION = 6;
constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_MEMBERS_MAX = 16;
constexpr int LOBBY_CODE_LENGTH = 4; constexpr int LOBBY_CODE_LENGTH = 4;
// Letters only, without I and O, so codes are easy to read out and type. // Letters only, without I and O, so codes are easy to read out and type.
+60 -12
View File
@@ -6,6 +6,11 @@
#include <cmath> #include <cmath>
#include <string> #include <string>
// 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; constexpr int BLINK_MILLISECONDS = 80;
// How long a claimed shot takes to turn from its old owner's colour to its new one's. // 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_INNER = 2;
constexpr float WHIP_JITTER = 0.5f; constexpr float WHIP_JITTER = 0.5f;
constexpr float WHIP_OUTER = 5; constexpr float WHIP_OUTER = 5;
constexpr float WHIP_TAPER = 0.35f;
constexpr float WISP_FRAME_STAGGER = 0.3f; constexpr float WISP_FRAME_STAGGER = 0.3f;
constexpr int TOMATO_BLOBS = 6; constexpr int TOMATO_BLOBS = 6;
constexpr float TOMATO_BLOB_MAX = 3.5f; constexpr float TOMATO_BLOB_MAX = 3.5f;
@@ -167,6 +173,8 @@ struct Stroke
float outer = 0; float outer = 0;
float inner = 0; float inner = 0;
float jitter = 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 // 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++) for (size_t i = 0; i + 1 < points.size(); i++)
{ {
float strength = base * falloff_at(distances[i] / reach); float strength = base * falloff_at(distances[i] / reach);
float width = math_lerp(1, stroke.taper, distances[i] / distance);
if (layer == 0) 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 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); 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) if (isMuted)
game.effects.flash_spawn(position, angle, size, game.color_get(owner)); 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) void Scene::fighter_afterimage_spawn(Fighter& fighter)
@@ -477,12 +495,22 @@ void Scene::attack_aura_spawn(Attack& attack)
tint_get(game, 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) void Scene::number_spawn(Damage& damage)
{ {
if (isMuted) if (isMuted)
return; 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); 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. // 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) else if (kind == ATTACK_WHIP)
{ {
// A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite // A plain lash keeps its full width while it unfurls, then thins away after the crack, slowly at first. It
// rides the end of the whip, pointing the way it runs. // 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))); 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 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<Vec2> points = std::vector<Vec2> points =
attack.is_charged() ? std::vector<Vec2>{attack.position, attack.tip} : lash_get(attack); attack.is_charged() ? std::vector<Vec2>{attack.position, attack.tip} : lash_get(attack);
Vec2 end = points.back(); Vec2 end = points.back();
Vec2 before = points[points.size() - 2]; Vec2 before = points[points.size() - 2];
strength_line_draw(game, attack, 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, game.sprites.effects.draw(renderer, "Whip Tip", 0,
{.color = color_fade(tint, 1 - fade), {.color = color_fade(tint, 1 - fade),
.position = end + shake, .position = end + shake,
@@ -1205,6 +1234,26 @@ void Scene::held_draw(Fighter& fighter)
hand_draw(fighter, held, 0, draw); 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 // 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. // 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) 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; float frame = SDL_GetTicks() * art.fps / SDL_MS_PER_SECOND;
if (tether) if (tether)
{ {
Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; draw.angle = vec2_angle(tether->position - draw.position);
draw.angle = vec2_angle(end - draw.position);
animation += " Base"; animation += " Base";
} }
else if (charge) else if (charge)
+3 -1
View File
@@ -3,6 +3,7 @@
#include "anm2.h" #include "anm2.h"
#include "sim.h" #include "sim.h"
struct Flash;
struct Game; struct Game;
// Shows the game this screen is on: draws its world, fighters and attacks, animates its fighters, and plays the cues it // 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 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 attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) override;
void shake_add(float amount) 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 fighter_afterimage_spawn(Fighter& fighter) override;
void attack_afterimage_spawn(Attack& attack) override; void attack_afterimage_spawn(Attack& attack) override;
void fighter_aura_spawn(Fighter& fighter) override; void fighter_aura_spawn(Fighter& fighter) override;
+1 -1
View File
@@ -51,7 +51,7 @@ void Session::start(Game& game)
game.chat.players_clear(); game.chat.players_clear();
bool isMaker = game.net.members.size() <= 1; bool isMaker = game.net.members.size() <= 1;
game.chat.system((isMaker ? "Created lobby " : "Joined lobby ") + game.net.code + 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. // Back to playing alone: this screen's fighter (if it had one) stays, in a sandbox of its own.
+234 -38
View File
@@ -36,6 +36,7 @@ constexpr const char* KEY_TRINKET_SLOTS = "trinket_slots";
constexpr const char* KEY_VOLUME = "volume"; constexpr const char* KEY_VOLUME = "volume";
constexpr const char* KEY_SHAKE = "screenshake"; constexpr const char* KEY_SHAKE = "screenshake";
constexpr const char* KEY_HUD_AIM = "hud_aim"; 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_LOADOUT = "loadout_";
constexpr const char* KEY_SERVER_URL = "server_url"; constexpr const char* KEY_SERVER_URL = "server_url";
constexpr const char* KEY_RULES_OPEN = "rules_open"; constexpr const char* KEY_RULES_OPEN = "rules_open";
@@ -43,6 +44,17 @@ constexpr float FOOTER_GAP = 4;
constexpr float LOADOUT_GAP = 4; constexpr float LOADOUT_GAP = 4;
constexpr const char* KEY_HUD_DPS = "hud_dps"; constexpr const char* KEY_HUD_DPS = "hud_dps";
constexpr const char* KEY_HUD_SPEED = "hud_speed"; 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_VSYNC = "vsync";
constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots"; constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots";
constexpr const char* SETTINGS_FILE = "settings.txt"; 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 const char* SANDBOX_START_WEAPON = "Sword";
constexpr float CLEAR_PROBE = 4; constexpr float CLEAR_PROBE = 4;
constexpr Uint64 WEB_SAVE_INTERVAL = 5000; 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() static std::string file_path_get()
{ {
@@ -165,16 +181,6 @@ static int rules_write(Rules& rules, char* text, int size)
return length; 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 // 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 // "name:level" (into levels by slot), or item levels as "name:level" (into levels by item). Returns how many entries
// it read. // it read.
@@ -268,6 +274,17 @@ static int sandbox_write(Fighter& fighter, char* text, int size)
return length; 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 <size_t COUNT> 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() Settings::Settings()
{ {
SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl));
@@ -302,6 +319,13 @@ void Settings::load(Game& game)
SDL_strlcpy(serverUrl, value, sizeof(serverUrl)); SDL_strlcpy(serverUrl, value, sizeof(serverUrl));
if (SDL_strcmp(line, KEY_RULES_OPEN) == 0) if (SDL_strcmp(line, KEY_RULES_OPEN) == 0)
isRulesOpen = number; 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) if (SDL_strcmp(line, KEY_VOLUME) == 0)
volume = std::clamp(number / PERCENT, 0.0f, 1.0f); volume = std::clamp(number / PERCENT, 0.0f, 1.0f);
if (SDL_strcmp(line, KEY_MUTE) == 0) if (SDL_strcmp(line, KEY_MUTE) == 0)
@@ -314,6 +338,26 @@ void Settings::load(Game& game)
isShaking = number; isShaking = number;
if (SDL_strcmp(line, KEY_HUD_DPS) == 0) if (SDL_strcmp(line, KEY_HUD_DPS) == 0)
isDpsShown = number; 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) if (SDL_strcmp(line, KEY_HUD_SPEED) == 0)
isSpeedShown = number; isSpeedShown = number;
if (SDL_strcmp(line, KEY_HUD_AIM) == 0) 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) if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0)
trinketSlots = std::clamp(number, 1, SLOT_MAX); trinketSlots = std::clamp(number, 1, SLOT_MAX);
rules_read(game.sim.match.rules, line, value); rules_read(game.sim.match.rules, line, value);
game.stats.read(line, value);
sandbox_read(sandbox, line, value); sandbox_read(sandbox, line, value);
for (int i = 0; i < LOADOUT_COUNT; i++) 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_TRINKET_SLOTS, trinketSlots, KEY_COLLISION, isCollisionShown, KEY_SHAKE, isShaking,
KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown, KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown,
KEY_SERVER_URL, serverUrl, KEY_RULES_OPEN, isRulesOpen); 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++) for (int i = 0; i < CONTROL_COUNT; i++)
{ {
Binding binding = game.input.bindings[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 += rules_write(match.rules, text + length, sizeof(text) - length);
length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length, length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length,
sizeof(text) - length); sizeof(text) - length);
length += game.stats.write(text + length, sizeof(text) - length);
for (int i = 0; i < LOADOUT_COUNT; i++) for (int i = 0; i < LOADOUT_COUNT; i++)
{ {
Loadout& loadout = loadouts[i]; Loadout& loadout = loadouts[i];
@@ -401,9 +456,9 @@ void Settings::save(Game& game)
void Settings::apply(Game& game) void Settings::apply(Game& game)
{ {
fullscreen_apply(game); window_apply(game);
game.renderer.vsync_set(isVsync); game.renderer.vsync_set(isVsync);
game.audio.gain_set(isMuted ? 0 : volume); game.input.aimDeadzone = stickDeadzone / PERCENT;
if (game.sim.match.state == MATCH_PRACTICE) if (game.sim.match.state == MATCH_PRACTICE)
{ {
Fighter& fighter = game.local_get(); 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, // Sets the sound's loudness when it changes: the volume, or silence while muted (or, with Mute on Unfocus, while the
// since SDL's own fullscreen there snaps the canvas into a corner. // window isn't focused).
void Settings::fullscreen_apply(Game& game) 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__ #ifdef __EMSCRIPTEN__
EM_ASM( EM_ASM(
@@ -426,6 +493,9 @@ void Settings::fullscreen_apply(Game& game)
isFullscreen); isFullscreen);
#else #else
SDL_SetWindowFullscreen(game.window, isFullscreen); 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 #endif
} }
@@ -433,7 +503,7 @@ void Settings::fullscreen_apply(Game& game)
void Settings::fullscreen_toggle(Game& game) void Settings::fullscreen_toggle(Game& game)
{ {
isFullscreen = !isFullscreen; isFullscreen = !isFullscreen;
fullscreen_apply(game); window_apply(game);
save(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"); ui.separator_draw(game, {row.x, y, row.w, row.h}, "Window");
bool isFullscreenChanged = false; bool isFullscreenChanged = false;
bool isVsyncChanged = 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; bool isShakeChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y)) 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."); ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Fills the whole screen.");
isShakeChanged = ui.toggle_draw(game, {x, y}, "Screenshake", isShaking); 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) if (isFullscreenChanged)
fullscreen_apply(game); window_apply(game);
if (isVsyncChanged) if (isVsyncChanged)
game.renderer.vsync_set(isVsync); game.renderer.vsync_set(isVsync);
if (ui.row_get(page, ROW_HEIGHT, y)) if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay"); ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay");
bool isHudChanged = false;
if (ui.row_get(page, ROW_HEIGHT, y)) 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, 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."); 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, y}, "DPS", isDpsShown);
isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed Meter", isSpeedShown); isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed", isSpeedShown);
} }
if (ui.row_get(page, ROW_HEIGHT, y)) 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, y}, "Aim Preview", isAimShown);
isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Display Collision", isCollisionShown); 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)) if (ui.row_get(page, ROW_HEIGHT, y))
ui.separator_draw(game, {row.x, y, row.w, row.h}, "Audio"); 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)) 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, 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, 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); controls_draw(game, page, row);
ui.page_end(game, page); 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. // 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; isCollisionShown = fresh.isCollisionShown;
isDpsShown = fresh.isDpsShown; isDpsShown = fresh.isDpsShown;
isSpeedShown = fresh.isSpeedShown; isSpeedShown = fresh.isSpeedShown;
isFpsShown = fresh.isFpsShown;
fpsLimit = fresh.fpsLimit;
isAimShown = fresh.isAimShown; 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)); SDL_strlcpy(serverUrl, fresh.serverUrl, sizeof(serverUrl));
weaponSlots = fresh.weaponSlots; weaponSlots = fresh.weaponSlots;
trinketSlots = fresh.trinketSlots; trinketSlots = fresh.trinketSlots;
fullscreen_apply(game); window_apply(game);
game.renderer.vsync_set(isVsync); game.renderer.vsync_set(isVsync);
game.audio.gain_set(isMuted ? 0 : volume);
if (game.sim.match.state == MATCH_PRACTICE) if (game.sim.match.state == MATCH_PRACTICE)
game.input.edit_send(game.devices[game.local], game.input.edit_send(game.devices[game.local],
edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots}));
isDirty = true; isDirty = true;
} }
// The lobby server's host, typed into a field (shown on the Multiplayer page), going back to the default when emptied // The online settings, two rows from row (here and on the Multiplayer page, each with its own label width): the lobby
// and saved once it's no longer being typed in. // server's host, typed into a field, going back to the default when emptied and saved once it's no longer being typed
void Settings::host_draw(Game& game, Rect field) // 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; 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)); isDirty |= ui.field_draw(game, field, serverUrl, sizeof(serverUrl));
if (serverUrl[0] == '\0' && !ui.is_active(serverUrl)) if (serverUrl[0] == '\0' && !ui.is_active(serverUrl))
SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(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; float icons = (ICON_PIXELS[ICON_SMALL] + LOADOUT_GAP) * (int)ITEM_KIND_COUNT;
if (ui.row_get(page, ROW_HEIGHT + icons, y)) if (ui.row_get(page, ROW_HEIGHT + icons, y))
loadouts_draw(game, {list.x, y, separator.w, WIDGET_HEIGHT}, hovered); 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); 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 // 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; }); isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; });
#endif #endif
wasPractice = isPractice; wasPractice = isPractice;
audio_apply(game);
loadouts_update(game); loadouts_update(game);
if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window)) if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window))
save(game); 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]]);
}
}
}
}
+23 -2
View File
@@ -28,6 +28,10 @@ struct SandboxSave
}; };
constexpr int LOADOUT_COUNT = 9; 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 // 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). // (none for a loadout never saved).
@@ -55,21 +59,35 @@ class Settings
public: public:
char name[NAME_LENGTH + 1] = "Fighter"; char name[NAME_LENGTH + 1] = "Fighter";
float volume = 1; 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 color = COLOR_UNASSIGNED;
int fashion = FASHION_NONE; int fashion = FASHION_NONE;
int trinketSlots = 2; int trinketSlots = 2;
int weaponSlots = 2; int weaponSlots = 2;
bool isAimShown = true; bool isAimShown = true;
bool isChatShown = true;
bool isCollisionShown = false; bool isCollisionShown = false;
bool isDamageShown = true;
bool isDpsShown = false; bool isDpsShown = false;
bool isFpsShown = false;
bool isFullscreen = false; bool isFullscreen = false;
bool isMuted = false; bool isMuted = false;
bool isMutedUnfocused = true;
bool isPingShown = true;
bool isRumbling = true;
bool isShaking = true; bool isShaking = true;
bool isSpeedShown = false; bool isSpeedShown = false;
bool isVsync = true; bool isVsync = true;
// The server online play goes through (see protocol.h), and whether the Play tab's rules are unfolded. // The server online play goes through (see protocol.h), and whether the Play tab's rules are unfolded.
char serverUrl[SERVER_URL_LENGTH] = {}; char serverUrl[SERVER_URL_LENGTH] = {};
bool isRulesOpen = true; 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). // The saved loadouts, and which one the loadout picker shows (counting from 1).
Loadout loadouts[LOADOUT_COUNT]; Loadout loadouts[LOADOUT_COUNT];
int loadoutShown = 1; int loadoutShown = 1;
@@ -78,11 +96,12 @@ public:
void load(Game& game); void load(Game& game);
void save(Game& game); void save(Game& game);
void apply(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 fullscreen_toggle(Game& game);
void draw(Game& game, Rect area, Page& page); void draw(Game& game, Rect area, Page& page);
void profile_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); void defaults_restore(Game& game);
int color_get(); int color_get();
// Marks the settings for saving (as the menu does), for changes made elsewhere, like chat commands. // 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 restore(Game& game);
void update(Game& game); void update(Game& game);
void loadouts_draw(Game& game, Rect row, Tooltip& hovered); void loadouts_draw(Game& game, Rect row, Tooltip& hovered);
void stats_draw(Game& game, Page& page, Rect row, Tooltip& hovered);
private: private:
SandboxSave sandbox; SandboxSave sandbox;
@@ -100,6 +120,7 @@ private:
Uint64 savedAt = 0; Uint64 savedAt = 0;
bool isDirty = false; bool isDirty = false;
bool wasPractice = true; bool wasPractice = true;
float appliedGain = -1;
int loadoutPasses = 0; int loadoutPasses = 0;
int loadoutPending = -1; int loadoutPending = -1;
+38
View File
@@ -16,3 +16,41 @@ if(MSVC)
else() else()
target_compile_options(sim PUBLIC -ffp-contract=off -fno-fast-math) target_compile_options(sim PUBLIC -ffp-contract=off -fno-fast-math)
endif() 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}")
+1 -1
View File
@@ -21,7 +21,7 @@ public:
{} {}
virtual void shake_add(float amount) 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) virtual void fighter_afterimage_spawn(Fighter& fighter)
{} {}
+123
View File
@@ -0,0 +1,123 @@
#include "stats.h"
#include "game.h"
#include <SDL3/SDL.h>
#include <algorithm>
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;
}
+51
View File
@@ -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);
};
+10
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstring>
#include "weapon.h" #include "weapon.h"
struct Game; 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; 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;
}
+244 -55
View File
@@ -54,6 +54,8 @@ constexpr float CHARGE_BAR_HEIGHT = 8;
constexpr float CHARGE_BAR_WIDTH = 20; constexpr float CHARGE_BAR_WIDTH = 20;
constexpr float DPS_SCALE = 2; constexpr float DPS_SCALE = 2;
constexpr float FIGHT_BANNER_TIME = 0.6f; 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_HEIGHT = 7;
constexpr float HEALTH_BAR_WIDTH = 28; constexpr float HEALTH_BAR_WIDTH = 28;
constexpr float ROUND_SCALE = 3; constexpr float ROUND_SCALE = 3;
@@ -114,6 +116,14 @@ constexpr float FIELD_INSET = 4;
constexpr float SLIDER_INSET = 3; constexpr float SLIDER_INSET = 3;
constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2; constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2;
constexpr float HINT_FADE = 0.5f; 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_OFFSET = 12;
constexpr float TOOLTIP_PADDING = 6; constexpr float TOOLTIP_PADDING = 6;
constexpr float TABLE_GAP = 8; 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, 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}; 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[] = { constexpr const char* TIPS[] = {
"Big hits launch fighters, and slamming into walls while launched hurts.", "Getting knocked into walls hurts! Do your best to reduce your speed when attacked!",
"Swing the Hammer into a wall to rebound off it, even mid-launch.", "Don't get cornered! Knockback damage can add up fast!",
"Ranged shots lose damage the farther they fly. Get close.", "Crouch to reduce knockback!",
"Explosions hit hardest at their centre.", "Crawl around by crouching and moving!",
"Your own explosions push you but never hurt you. Rocket jump!", "Roll around on sloped surfaces with Down!",
"Arrows stuck in walls are solid to you. Stand on them.", "Platforms block shots. Use them as cover!",
"Charged boomerangs are platforms only you can ride.",
"Standing in your own flames brakes you, even mid-launch.", "Weapons don't share cooldowns! Switch Weapons to maximize damage!",
"Shots carry your momentum. Fire while charging forward for extra damage.", "When a Weapon enters cooldown, it will fade and return to normal when it's done!",
"Every hit is stronger the faster you're moving.", "Your cursor's color and vibration can show you your Weapon's cooldown or Charge!",
"Let go of a charge early for a normal attack that hits harder.", "Let go of a Charge early for a Basic Attack that hits harder!",
"Mines need a moment to arm before they'll go off.", "Your Charge carries over when you swap Weapons!",
"The Whip's charged blast only lands at the very tip.", "Higher damaging attacks will glow and vibrate!",
"A charged grapple hit drags you straight to your target.", "Going faster makes you glow and vibrate, showing that your attacks deal more damage!",
"Platforms block shots. Use them as cover.",
"With Iron Weights, hold Down to slam the ground. Faster falls hit harder.", "Almost every attack counts as a projectile! See how you can change their traits with Trinkets!",
"Ricochet shots add damage with every bounce.", "Projectiles inherit your velocity. Go fast for big damage!",
"Your charge carries over when you swap weapons.", "Projectiles lose damage the longer they're out. Get close!",
"Charge recalls your boomerangs once they're all out.", "Projectiles will fade with time; this fade shows their damage potential!",
"Attack or Charge reels a thrown flail ball back in.", "Increasing a projectile's lifetime or speed can both increase its range!",
"Uzi and Blaster hits barely push. Melee and explosions send fighters flying.", "Explosions hit hardest at their center!",
"Rapid small hits can't juggle you. Knockback tops up, it doesn't stack.", "Your explosions don't hurt, and only push you. Try rocket jumping!",
"Crack the Whip against a wall to be pulled toward it.", "Recoil is movement. Fire away from where you want to go!",
"Recoil is movement. Fire away from where you want to go.",
"Splitter copies fan wider with each level.", "Mix and match Weapons and Trinkets for nigh-infinite amounts of combos!",
"Dual Wield fires your previous weapon from your off hand too.", "The Fist's Charged Attack doesn't always send you straight up! Change your aim!",
"Tesla Coil chains lightning between your weapons out in the world.", "Slam a Hammer into surfaces to fly far!",
"Each round levels up every item, and health rises with it.", "The Lance's Charged Attack lunges you forward!",
"With the Feather, hold Up in the air to float.", "Crack the Whip against a wall to be pulled toward it!",
"The Lance's charged poke lunges you forward.", "The Whip's Charged Attack only blasts at the very tip, but does big damage!",
"In a race, dithered pits send you back to the last solid ground you stood on.", "Charge a Flail to throw its ball, and Charge again to reel it back!",
"Race blocks grow back a few seconds after they break. Don't dawdle.", "You can have the Flail's ball extended and another Weapon equipped for a dangerous combo!",
"Hits in a race don't hurt, but they still knock. Push rivals into pits.", "Charged Boomerangs are platforms only you can ride!",
"Shotgun recoil can double as a jump.", "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 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. // 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) static float hover_alpha_get(float hover)
@@ -287,7 +360,7 @@ void Ui::bars_draw(Game& game)
const char* name = name_get(game, i); const char* name = name_get(game, i);
Vec2 size = game.text.size_get(name); Vec2 size = game.text.size_get(name);
bottom -= size.y; 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 width = size.x + (ping == PING_NONE ? 0 : NAME_PING_GAP + ping_width_get(game, ping));
float left = std::round(centerX - width / 2); float left = std::round(centerX - width / 2);
game.text.draw(renderer, name, {.color = color, .position = {left, bottom}}); 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); 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 // 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. // fight, the end of a round and the end of the match.
void Ui::match_draw(Game& game) void Ui::match_draw(Game& game)
@@ -325,12 +398,7 @@ void Ui::match_draw(Game& game)
Input& input = game.input; Input& input = game.input;
if (match.state == MATCH_PRACTICE) if (match.state == MATCH_PRACTICE)
{ {
char key[TOOLTIP_LENGTH]; hints_draw(game);
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);
return; return;
} }
@@ -340,8 +408,8 @@ void Ui::match_draw(Game& game)
else else
{ {
// The score: each fighter's wins in its own colour, the dashes between them white. It's centred along the top, // 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 // unless a long weapon hotbar reaches it in the HUD, when it moves right to clear the hotbar (the clock under
// with it). // it moves with it). It stays put while the menu moves the hotbar.
Renderer& renderer = game.renderer; Renderer& renderer = game.renderer;
int length = 0; int length = 0;
for (int i = 0; i < game.sim.fighterCount; i++) 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}; Vec2 scale = {SCORE_SCALE, SCORE_SCALE};
float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x;
int weapons = game.local_get().weaponSlots; 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 hotbarRight = last.x + last.w;
float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP));
float center = x + scoreWidth / 2; 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)}}); HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}});
} }
countdown_beep(game);
switch (match.state) switch (match.state)
{ {
case MATCH_COUNTDOWN: case MATCH_COUNTDOWN:
@@ -439,6 +508,12 @@ void Ui::match_draw(Game& game)
} }
if (match.timer > -FIGHT_BANNER_TIME) if (match.timer > -FIGHT_BANNER_TIME)
text_center_draw(game, isRace ? "Go!" : "Fight!", BANNER_Y, BANNER_SCALE); 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; break;
case MATCH_ROUND_OVER: case MATCH_ROUND_OVER:
if (match.winner >= 0) if (match.winner >= 0)
@@ -828,6 +903,108 @@ static CountdownInfo countdown_info_get(Game& game)
return info; 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 // 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 // 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 // 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}}); {.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 // Top right: during a match, the local fighter's health as a number beside a bar, then, smaller, frames drawn per
// local fighter's speed in units per second. // second (counted over each second), damage per second and the local fighter's speed in units per second.
void Ui::status_draw(Game& game) void Ui::status_draw(Game& game)
{ {
char text[TOOLTIP_LENGTH]; char text[TOOLTIP_LENGTH];
@@ -918,13 +1095,26 @@ void Ui::status_draw(Game& game)
y += rowHeight; 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) { 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); left = std::min(left, x);
game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}, .scale = {DPS_SCALE, DPS_SCALE}}); game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}});
y += rowHeight; 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) if (game.settings.isDpsShown)
{ {
SDL_snprintf(text, sizeof(text), "%.1f DPS", game.effects.dps_get()); 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. // Keeps the HUD's timers and the cooldown bars up to date.
void Ui::update(Game& game) void Ui::update(Game& game)
{ {
isMenuSeen |= game.menu.isOpen; hints_update(game);
if (isMenuSeen)
hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE);
spectate_update(game); spectate_update(game);
// The HUD's elements change while the mouse is over them, and change back after. // 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). // A fighter's connection for its window, online (PING_NONE otherwise).
static float rival_ping_get(Game& game, int fighter) 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 // Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more
+28 -2
View File
@@ -198,6 +198,24 @@ struct Slot
// How wide a fighter preview's column is (see Ui::fighter_preview_draw). // How wide a fighter preview's column is (see Ui::fighter_preview_draw).
constexpr float FIGHTER_PREVIEW_WIDTH = 120; 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 class Ui
{ {
public: public:
@@ -292,7 +310,7 @@ private:
bool isFocusing = false; bool isFocusing = false;
bool isScrolled = false; bool isScrolled = false;
char numberText[TOOLTIP_LENGTH] = {}; char numberText[TOOLTIP_LENGTH] = {};
float hintAlpha = 1; float hintAlphas[HINT_COUNT] = {};
// The switching controls as they were last tick, to catch presses while spectating. // The switching controls as they were last tick, to catch presses while spectating.
bool spectateHeld[3] = {}; bool spectateHeld[3] = {};
// How far the mouse being over each HUD element has changed it, from 0 to 1: hotbar slots (weapons, then trinkets) // 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 slotHovers[ITEM_KIND_COUNT][SLOT_MAX] = {};
float rivalHovers[MAX_FIGHTERS] = {}; float rivalHovers[MAX_FIGHTERS] = {};
float statusHover = 0; 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 loadoutTop = 0;
float widgetAlpha = 1; float widgetAlpha = 1;
int tip = 0; int tip = 0;
// The second of the countdown last beeped for (see countdown_beep).
int countdownBeeped = 0;
bool isActiveSeen = false; bool isActiveSeen = false;
bool isMenuSeen = false;
bool isTipChosen = false; bool isTipChosen = false;
bool isWidgetEnabled = true; bool isWidgetEnabled = true;
bool isWidgetLive = false; bool isWidgetLive = false;
@@ -317,6 +340,9 @@ private:
void race_draw(Game& game); void race_draw(Game& game);
float results_draw(Game& game, float y); float results_draw(Game& game, float y);
void countdown_aside_draw(Game& game); 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, float alpha = 1);
void text_center_draw(Game& game, const char* text, float y, float scale, Color color); void text_center_draw(Game& game, const char* text, float y, float scale, Color color);
void hotbar_draw(Game& game); void hotbar_draw(Game& game);
+125 -43
View File
@@ -1,7 +1,9 @@
#include "weapon.h" #include "weapon.h"
#include "sim.h" #include "sim.h"
#include <algorithm> #include <algorithm>
#include <array>
#include <functional> #include <functional>
#include <string_view>
constexpr int ATTACKS_MAX = 255; constexpr int ATTACKS_MAX = 255;
constexpr int DAMAGE_STEP = 5; constexpr int DAMAGE_STEP = 5;
@@ -67,8 +69,21 @@ constexpr float UPPERCUT_REACH = 16;
constexpr float VOLLEY_GAP = 6; constexpr float VOLLEY_GAP = 6;
constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_CONE = 0.25f;
constexpr float WHIP_HANDLE = 4; constexpr float WHIP_HANDLE = 4;
constexpr float WHIP_COIL_GAP = 3; // A plain lash's motion, as shares of its lifetime: the handle draws back until WHIP_WINDUP, drives forward until
constexpr int WHIP_COIL_STEPS = 48; // 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 WHIP_PULL = 220;
constexpr float WOBBLE_IMPULSE = 0.15f; 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 // 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); 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<Vec2, WEAPON_COUNT> MUZZLES = [] {
constexpr Muzzle NULLS[] = {WEAPON_MUZZLES{nullptr, 0, 0}};
std::array<Vec2, WEAPON_COUNT> 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. // 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) float fill_get(float strength)
{ {
return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); 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 // Where a plain lash's handle points at a share of its lifetime, off the aim (see WHIP_WINDUP): eased back slowly,
// even spiral (its rings WHIP_COIL_GAP apart), curling up and in toward its end, so the coil unwinds as it unrolls. It // driven forward sharply over the top, held along the aim through the crack, then drooping.
// lies straight as it cracks, and coming back it winds up again on the other side, below. 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<Vec2> lash_get(Attack& attack) std::vector<Vec2> lash_get(Attack& attack)
{ {
Vec2 direction = vec2_polar(attack.angle, 1); float time = attack.progress_get();
Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x}; float recoil = std::max(0.0f, (time - PATH_OUT) / (1 - PATH_OUT));
up = attack.progress_get() > PATH_OUT ? up * -1 : up; float slack = time < PATH_OUT ? std::max(0.0f, 1 - time / WHIP_THROW) : std::min(1.0f, 2 * recoil);
float coil = attack.length - vec2_length(attack.tip - attack.position); float kick = time < PATH_OUT
std::vector<Vec2> points = {attack.position, attack.tip}; ? 0
if (coil < 1) : WHIP_KICK * math_sin(2 * PI * WHIP_WOBBLES * std::sqrt(recoil)) * math_pow(WHIP_DAMPING, recoil);
return points; 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 std::vector<Vec2> points = {attack.position};
// its ring spacing over a full turn. for (int i = 0; i < WHIP_LINKS; i++)
float turn = direction.x * up.y - direction.y * up.x > 0 ? 1 : -1;
float spacing = WHIP_COIL_GAP * attack.scale / (2 * PI);
float heading = attack.angle;
float step = coil / WHIP_COIL_STEPS;
Vec2 point = attack.tip;
for (int i = 0; i < WHIP_COIL_STEPS; i++)
{ {
heading += turn * step / std::sqrt(2 * spacing * (coil - i * step)); float along = (i + 0.5f) / WHIP_LINKS;
point = point + vec2_polar(heading, step); float heading = lash_heading_get(time - (PATH_OUT - WHIP_THROW) * math_pow(along, WHIP_TAPER));
points.push_back(point); heading += kick * along * along;
heading += (down - heading) * WHIP_SAG * slack * along;
points.push_back(points.back() + lash_direction_get(attack, heading) * step);
} }
return points; 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 // 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) int shapes_get(Sim& sim, Attack& attack, Capsule* shapes)
{ {
if (attack.def()->kind != ATTACK_WHIP || attack.is_charged()) 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) { auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(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++) for (int ray = 0; ray < WHIP_RAYS; ray++)
{ {
float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1); 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(), .isLeft = fighter.is_left(),
.isSecondary = shot.isSecondary, .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). // Charging burns a grenade's fuse down (fully charged, it goes off in the barrel, see below).
if (kind == ATTACK_GRENADE) if (kind == ATTACK_GRENADE)
@@ -808,8 +890,8 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
piece.aimAngle += offset; piece.aimAngle += offset;
piece.velocity = vec2_polar(piece.angle, stats.speed + push); piece.velocity = vec2_polar(piece.angle, stats.speed + push);
// A slash sets off a little ahead of the hand, clear of its fighter. // 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; float slash = SLASH_DISTANCE * piece.scale + SLASH_GAP;
piece.position = piece.origin + vec2_polar(piece.angle, muzzle); piece.position = kind == ATTACK_SLASH ? piece.origin + vec2_polar(piece.angle, slash) : muzzle_get(piece);
piece.isSecondary |= split != splits / 2; piece.isSecondary |= split != splits / 2;
piece.isCopy = shot.isEcho || split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2;
if (def.isOrbiting) 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); sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound);
Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE;
float side = fighter.is_left() ? -1 : 1; float side = fighter.is_left() ? -1 : 1;
if (weapon.flash > 0) 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) if (weapon.isEjecting)
sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK),
fighter.index); fighter.index);
@@ -1077,10 +1158,8 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
case ATTACK_WHIP: case ATTACK_WHIP:
{ {
// A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at // 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 // once. A plain lash goes with the hand, as long as its reach along the aim (short of any wall), its handle
// across its cone: in from the upper edge as it unrolls, straight along the aim as it cracks at full reach // and links moving as lash_get says. It hits anywhere in its cone as far as it reaches until it cracks.
// (short of any wall), and on down to the lower edge as it comes back. It hits anywhere in the cone out to
// its front until it cracks.
if (attack.is_charged()) if (attack.is_charged())
{ {
attack.origin = fighter.hand_get(attack.hand); attack.origin = fighter.hand_get(attack.hand);
@@ -1092,15 +1171,12 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
auto isBlocked = [&](Vec2 point) { auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(point); return sim.world.is_blocking_at(point);
}; };
float out = vec2_length(attack.position - attack.origin); Vec2 reach = ray_march(attack.origin, attack.aimAngle, reach_at_get(attack), isBlocked);
float unfurl = std::min(1.0f, progress / PATH_OUT); attack.angle = vec2_angle(lash_direction_get(attack, lash_heading_get(progress)));
float sweep = progress < PATH_OUT ? unfurl - 1 : (progress - PATH_OUT) / (1 - PATH_OUT);
attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * std::min(1.0f, sweep);
Vec2 reach = ray_march(attack.origin, attack.angle, reach_at_get(attack), isBlocked);
attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE);
attack.length = std::max(0.0f, vec2_length(reach - attack.position)); attack.length = std::max(0.0f, vec2_length(reach - attack.origin) - WHIP_HANDLE);
attack.tip = attack.position + vec2_polar(attack.angle, std::clamp(out - WHIP_HANDLE, 0.0f, attack.length)); attack.tip = lash_get(attack).back();
attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0; attack.hitsLeft = progress < PATH_OUT ? attack.hitsLeft : 0;
break; break;
} }
case ATTACK_SPIN: 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; float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1;
attacker.hitstop_add(damage.hitstop); attacker.hitstop_add(damage.hitstop);
damage.knock *= attacker.stat_get(STAT_KNOCKBACK); 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) if (damage.target < MAX_FIGHTERS)
sim.fighters[damage.target].damage_take(sim, damage); sim.fighters[damage.target].damage_take(sim, damage);
else else
@@ -1829,7 +1907,11 @@ std::vector<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1);
float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim);
Vec2 velocity = isArrow ? aim * speed : aim; Vec2 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; int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0;
Path path; Path path;
bool isEnded = stats.range <= TRAJECTORY_REACH; bool isEnded = stats.range <= TRAJECTORY_REACH;
+154 -135
View File
@@ -388,14 +388,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.spread = 5}, .spread = 5},
.levels = .levels =
{ {
{.normal = {.damage = 14, .lifetime = 0.12f, .speed = 283}, {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 283},
.charged = {.damage = 50, .lifetime = 0.35f, .lunge = 420, .scale = 1}, .charged = {.damage = 58, .lifetime = 0.35f, .lunge = 420, .scale = 1},
.cooldown = 0.1f}, .cooldown = 0.1f},
{.normal = {.damage = 16, .lifetime = 0.12f, .speed = 333}, {.normal = {.damage = 18, .lifetime = 0.12f, .speed = 333},
.charged = {.damage = 69, .lifetime = 0.35f, .lunge = 500, .scale = 1.4f}, .charged = {.damage = 80, .lifetime = 0.35f, .lunge = 500, .scale = 1.4f},
.cooldown = 0.07f}, .cooldown = 0.07f},
{.normal = {.damage = 19, .lifetime = 0.12f, .speed = 383}, {.normal = {.damage = 22, .lifetime = 0.12f, .speed = 383},
.charged = {.damage = 88, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f}, .charged = {.damage = 102, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f},
.cooldown = 0.045f}, .cooldown = 0.045f},
}, },
.bot = {.rangeMax = 36}, .bot = {.rangeMax = 36},
@@ -431,14 +431,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.damage = 18, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60},
.charged = {.damage = 36, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, .charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
{.normal = {.damage = 24, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60},
.charged = {.damage = 48, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, .charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
{.normal = {.damage = 30, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60},
.charged = {.damage = 60, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
}, },
.bot = {.rangeMax = 48}, .bot = {.rangeMax = 48},
@@ -475,14 +475,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isWielded = true}, .isWielded = true},
.levels = .levels =
{ {
{.normal = {.damage = 20, .lifetime = 0.3f, .range = 21.43f, .scale = 1.07f, .speed = 258}, {.normal = {.damage = 19, .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}, .charged = {.damage = 38, .lifetime = 0.45f, .range = 45.71f, .scale = 2.29f, .speed = 367},
.cooldown = 0.5f}, .cooldown = 0.5f},
{.normal = {.damage = 28, .lifetime = 0.3f, .range = 24.29f, .scale = 1.21f, .speed = 292}, {.normal = {.damage = 27, .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}, .charged = {.damage = 53, .lifetime = 0.45f, .range = 51.43f, .scale = 2.57f, .speed = 413},
.cooldown = 0.5f}, .cooldown = 0.5f},
{.normal = {.damage = 34, .lifetime = 0.3f, .range = 27.14f, .scale = 1.36f, .speed = 327}, {.normal = {.damage = 32, .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}, .charged = {.damage = 66, .lifetime = 0.45f, .range = 57.14f, .scale = 2.86f, .speed = 459},
.cooldown = 0.5f}, .cooldown = 0.5f},
}, },
.bot = {.rangeMax = 36}, .bot = {.rangeMax = 36},
@@ -514,15 +514,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isWielded = true}, .isWielded = true},
.levels = .levels =
{ {
{.normal = {.damage = 15, .lifetime = 0.29f, .speed = 890}, {.normal = {.damage = 10, .lifetime = 0.29f, .speed = 890},
.charged = {.damage = 45, .lifetime = 0.29f, .lunge = 200, .speed = 890}, .charged = {.damage = 28, .lifetime = 0.29f, .lunge = 200, .speed = 890},
.cooldown = 0.35f}, .cooldown = 0.55f},
{.normal = {.damage = 20, .lifetime = 0.31f, .speed = 970}, {.normal = {.damage = 14, .lifetime = 0.31f, .speed = 970},
.charged = {.damage = 60, .lifetime = 0.31f, .lunge = 250, .speed = 970}, .charged = {.damage = 37, .lifetime = 0.31f, .lunge = 250, .speed = 970},
.cooldown = 0.35f}, .cooldown = 0.55f},
{.normal = {.damage = 25, .lifetime = 0.33f, .speed = 1040}, {.normal = {.damage = 17, .lifetime = 0.33f, .speed = 1040},
.charged = {.damage = 75, .lifetime = 0.33f, .lunge = 300, .speed = 1040}, .charged = {.damage = 46, .lifetime = 0.33f, .lunge = 300, .speed = 1040},
.cooldown = 0.35f}, .cooldown = 0.55f},
}, },
.bot = {.rangeMin = 40, .rangeMax = 140}, .bot = {.rangeMin = 40, .rangeMax = 140},
}, },
@@ -556,15 +556,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isInstant = true}, .isInstant = true},
.levels = .levels =
{ {
{.normal = {.damage = 18, .lifetime = 0.38f, .speed = 950}, {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 950},
.charged = {.area = 6, .damage = 42, .range = 125}, .charged = {.area = 6, .damage = 46, .range = 125},
.cooldown = 0.45f}, .cooldown = 0.4f},
{.normal = {.damage = 21, .lifetime = 0.38f, .speed = 1175}, {.normal = {.damage = 28, .lifetime = 0.38f, .speed = 1175},
.charged = {.area = 7, .damage = 63, .range = 155}, .charged = {.area = 7, .damage = 70, .range = 155},
.cooldown = 0.45f}, .cooldown = 0.4f},
{.normal = {.damage = 24, .lifetime = 0.38f, .speed = 1400}, {.normal = {.damage = 32, .lifetime = 0.38f, .speed = 1400},
.charged = {.area = 8, .damage = 84, .range = 185}, .charged = {.area = 8, .damage = 93, .range = 185},
.cooldown = 0.45f}, .cooldown = 0.4f},
}, },
.bot = {.rangeMin = 90, .rangeMax = 160}, .bot = {.rangeMin = 90, .rangeMax = 160},
}, },
@@ -605,14 +605,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isWielded = true}, .isWielded = true},
.levels = .levels =
{ {
{.normal = {.damage = 13, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, {.normal = {.damage = 11, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518},
.charged = {.damage = 40, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450}, .charged = {.damage = 35, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450},
.cooldown = 0.5f}, .cooldown = 0.5f},
{.normal = {.damage = 18, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, {.normal = {.damage = 15, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644},
.charged = {.damage = 50, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450}, .charged = {.damage = 44, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450},
.cooldown = 0.5f}, .cooldown = 0.5f},
{.normal = {.damage = 22, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, {.normal = {.damage = 19, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738},
.charged = {.damage = 60, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, .charged = {.damage = 51, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450},
.cooldown = 0.5f}, .cooldown = 0.5f},
}, },
.bot = {.rangeMax = 60}, .bot = {.rangeMax = 60},
@@ -655,16 +655,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isWielded = true}, .isWielded = true},
.levels = .levels =
{ {
{.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600},
.charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850},
.cooldown = 0.2f, .cooldown = 0.2f,
.count = 1}, .count = 1},
{.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600},
.charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850},
.cooldown = 0.2f, .cooldown = 0.2f,
.count = 2}, .count = 2},
{.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600},
.charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850},
.cooldown = 0.2f, .cooldown = 0.2f,
.count = 3}, .count = 3},
}, },
@@ -708,17 +708,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isWielded = true}, .isWielded = true},
.levels = .levels =
{ {
{.normal = {.damage = 16, .range = 155, .speed = 600}, {.normal = {.damage = 19, .range = 175, .speed = 600},
.charged = {.damage = 32, .lifetime = 0.1f, .range = 250, .speed = 400}, .charged = {.damage = 35, .lifetime = 0.1f, .range = 250, .speed = 400},
.cooldown = 0.85f, .cooldown = 0.7f,
.reel = 120}, .reel = 120},
{.normal = {.damage = 22, .range = 155, .speed = 700}, {.normal = {.damage = 25, .range = 175, .speed = 700},
.charged = {.damage = 45, .lifetime = 0.1f, .range = 300, .speed = 450}, .charged = {.damage = 48, .lifetime = 0.1f, .range = 300, .speed = 450},
.cooldown = 0.85f, .cooldown = 0.7f,
.reel = 135}, .reel = 135},
{.normal = {.damage = 28, .range = 155, .speed = 800}, {.normal = {.damage = 31, .range = 175, .speed = 800},
.charged = {.damage = 58, .lifetime = 0.1f, .range = 350, .speed = 500}, .charged = {.damage = 61, .lifetime = 0.1f, .range = 350, .speed = 500},
.cooldown = 0.85f, .cooldown = 0.7f,
.reel = 150}, .reel = 150},
}, },
.bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD}, .bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD},
@@ -763,14 +763,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isShot = true}, .isShot = true},
.levels = .levels =
{ {
{.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, {.normal = {.damage = 21, .range = 360, .speed = 320, .volley = 1},
.charged = {.damage = 22, .range = 500, .speed = 560, .volley = 1}, .charged = {.damage = 21, .range = 500, .speed = 560, .volley = 1},
.cooldown = 0.6f}, .cooldown = 0.6f},
{.normal = {.damage = 22, .range = 360, .speed = 360, .volley = 2}, {.normal = {.damage = 21, .range = 360, .speed = 360, .volley = 2},
.charged = {.damage = 30, .range = 500, .speed = 670, .volley = 2}, .charged = {.damage = 29, .range = 500, .speed = 670, .volley = 2},
.cooldown = 0.6f}, .cooldown = 0.6f},
{.normal = {.damage = 22, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3}, {.normal = {.damage = 21, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3},
.charged = {.damage = 37, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3}, .charged = {.damage = 36, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3},
.cooldown = 0.6f}, .cooldown = 0.6f},
}, },
.bot = {.rangeMin = 100, .rangeMax = 260, .charge = CHARGE_ALWAYS, .aim = AIM_LEAD}, .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}, {.normal = {.damage = 10, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850},
.charged = {.damage = 12, .lunge = -120, .range = RANGE_INFINITE}, .charged = {.damage = 12, .lunge = -120, .range = RANGE_INFINITE},
.cooldown = 0.2f}, .cooldown = 0.2f},
{.normal = {.damage = 13, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, {.normal = {.damage = 14, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850},
.charged = {.damage = 24, .lunge = -120, .range = RANGE_INFINITE}, .charged = {.damage = 25, .lunge = -120, .range = RANGE_INFINITE},
.cooldown = 0.2f}, .cooldown = 0.2f},
{.normal = {.damage = 16, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, {.normal = {.damage = 17, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850},
.charged = {.damage = 36, .lunge = -120, .range = RANGE_INFINITE}, .charged = {.damage = 37, .lunge = -120, .range = RANGE_INFINITE},
.cooldown = 0.2f}, .cooldown = 0.2f},
}, },
.bot = {.rangeMin = 80, .rangeMax = 320, .aim = AIM_LEAD}, .bot = {.rangeMin = 80, .rangeMax = 320, .aim = AIM_LEAD},
@@ -862,8 +862,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isShot = true}, .isShot = true},
.levels = .levels =
{ {
{.normal = {.damage = 2, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, {.normal = {.damage = 4.5f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800},
.charged = {.damage = 4, .charged = {.damage = 4.5f,
.fade = 0.45f, .fade = 0.45f,
.fan = 0.2f, .fan = 0.2f,
.lunge = -60, .lunge = -60,
@@ -871,8 +871,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.speed = 1200, .speed = 1200,
.volley = 5}, .volley = 5},
.cooldown = 0.12f}, .cooldown = 0.12f},
{.normal = {.damage = 4, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, {.normal = {.damage = 6.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800},
.charged = {.damage = 8, .charged = {.damage = 9,
.fade = 0.45f, .fade = 0.45f,
.fan = 0.2f, .fan = 0.2f,
.lunge = -60, .lunge = -60,
@@ -880,8 +880,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.speed = 1200, .speed = 1200,
.volley = 7}, .volley = 7},
.cooldown = 0.075f}, .cooldown = 0.075f},
{.normal = {.damage = 6, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, {.normal = {.damage = 8.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800},
.charged = {.damage = 12, .charged = {.damage = 14,
.fade = 0.45f, .fade = 0.45f,
.fan = 0.2f, .fan = 0.2f,
.lunge = -60, .lunge = -60,
@@ -934,15 +934,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isShot = true}, .isShot = true},
.levels = .levels =
{ {
{.normal = {.damage = 6, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5}, {.normal = {.damage = 5.75f, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5},
.charged = {.damage = 28, .lunge = -330, .range = 250, .speed = 900}, .charged = {.damage = 26, .lunge = -330, .range = 250, .speed = 900},
.cooldown = 1.0f}, .cooldown = 1.1f},
{.normal = {.damage = 8, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6}, {.normal = {.damage = 7.5f, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6},
.charged = {.damage = 37, .lunge = -330, .range = 250, .speed = 900}, .charged = {.damage = 35, .lunge = -330, .range = 250, .speed = 900},
.cooldown = 1.0f}, .cooldown = 1.1f},
{.normal = {.damage = 10, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7}, {.normal = {.damage = 9.5f, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7},
.charged = {.damage = 46, .lunge = -330, .range = 250, .speed = 900}, .charged = {.damage = 43, .lunge = -330, .range = 250, .speed = 900},
.cooldown = 1.0f}, .cooldown = 1.1f},
}, },
.bot = {.rangeMax = 90, .aim = AIM_LEAD}, .bot = {.rangeMax = 90, .aim = AIM_LEAD},
.isEjecting = true, .isEjecting = true,
@@ -989,15 +989,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.damage = 10, .range = 660, .speed = 260}, {.normal = {.damage = 7.5f, .range = 660, .speed = 260},
.charged = {.damage = 24, .range = 660, .speed = 220}, .charged = {.damage = 19, .range = 660, .speed = 220},
.cooldown = 0.6f}, .cooldown = 0.65f},
{.normal = {.damage = 13, .range = 840, .speed = 260}, {.normal = {.damage = 9.25f, .range = 840, .speed = 260},
.charged = {.damage = 32, .range = 840, .speed = 220}, .charged = {.damage = 24, .range = 840, .speed = 220},
.cooldown = 0.6f}, .cooldown = 0.65f},
{.normal = {.damage = 16, .range = 1020, .speed = 260}, {.normal = {.damage = 12, .range = 1020, .speed = 260},
.charged = {.damage = 40, .range = 1020, .speed = 220}, .charged = {.damage = 31, .range = 1020, .speed = 220},
.cooldown = 0.6f}, .cooldown = 0.65f},
}, },
.bot = {.rangeMin = 30, .rangeMax = 200, .aim = AIM_FEET}, .bot = {.rangeMin = 30, .rangeMax = 200, .aim = AIM_FEET},
}, },
@@ -1042,14 +1042,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.area = 28, .damage = 14, .lunge = -120, .range = 500, .speed = 120}, {.normal = {.area = 28, .damage = 13, .lunge = -120, .range = 500, .speed = 120},
.charged = {.area = 46, .damage = 29, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .charged = {.area = 46, .damage = 27, .lunge = -220, .range = 600, .scale = 2, .speed = 60},
.cooldown = 0.9f}, .cooldown = 0.9f},
{.normal = {.area = 35, .damage = 18, .lunge = -120, .range = 500, .speed = 120}, {.normal = {.area = 35, .damage = 17, .lunge = -120, .range = 500, .speed = 120},
.charged = {.area = 58, .damage = 39, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .charged = {.area = 58, .damage = 36, .lunge = -220, .range = 600, .scale = 2, .speed = 60},
.cooldown = 0.9f}, .cooldown = 0.9f},
{.normal = {.area = 42, .damage = 22, .lunge = -120, .range = 500, .speed = 120}, {.normal = {.area = 42, .damage = 20, .lunge = -120, .range = 500, .speed = 120},
.charged = {.area = 69, .damage = 50, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .charged = {.area = 69, .damage = 47, .lunge = -220, .range = 600, .scale = 2, .speed = 60},
.cooldown = 0.9f}, .cooldown = 0.9f},
}, },
.bot = {.rangeMin = 80, .rangeMax = 220, .aim = AIM_GROUND}, .bot = {.rangeMin = 80, .rangeMax = 220, .aim = AIM_GROUND},
@@ -1103,15 +1103,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.area = 32, .damage = 30, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, {.normal = {.area = 32, .damage = 34, .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}, .charged = {.area = 32, .damage = 34, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340},
.cooldown = 0.9f}, .cooldown = 0.8f},
{.normal = {.area = 38, .damage = 38, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, {.normal = {.area = 38, .damage = 43, .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}, .charged = {.area = 38, .damage = 43, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340},
.cooldown = 0.9f}, .cooldown = 0.8f},
{.normal = {.area = 44, .damage = 46, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, {.normal = {.area = 44, .damage = 52, .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}, .charged = {.area = 44, .damage = 52, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340},
.cooldown = 0.9f}, .cooldown = 0.8f},
}, },
.bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_PLANNED, .aim = AIM_LEAD}, .bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_PLANNED, .aim = AIM_LEAD},
.isChargeScaled = true, .isChargeScaled = true,
@@ -1139,16 +1139,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isDrifting = true}, .isDrifting = true},
.levels = .levels =
{ {
{.normal = {.area = 56, .damage = 55, .range = 120}, {.normal = {.area = 56, .damage = 56, .range = 120},
.charged = {.area = 56, .damage = 55, .lifetime = 15, .range = 120, .speed = 720}, .charged = {.area = 56, .damage = 56, .lifetime = 15, .range = 120, .speed = 720},
.cooldown = 0.3f, .cooldown = 0.3f,
.count = 2}, .count = 2},
{.normal = {.area = 66, .damage = 77, .range = 120}, {.normal = {.area = 66, .damage = 78, .range = 120},
.charged = {.area = 66, .damage = 77, .lifetime = 15, .range = 120, .speed = 720}, .charged = {.area = 66, .damage = 78, .lifetime = 15, .range = 120, .speed = 720},
.cooldown = 0.3f, .cooldown = 0.3f,
.count = 4}, .count = 4},
{.normal = {.area = 76, .damage = 99, .range = 120}, {.normal = {.area = 76, .damage = 101, .range = 120},
.charged = {.area = 76, .damage = 99, .lifetime = 15, .range = 120, .speed = 720}, .charged = {.area = 76, .damage = 101, .lifetime = 15, .range = 120, .speed = 720},
.cooldown = 0.3f, .cooldown = 0.3f,
.count = 6}, .count = 6},
}, },
@@ -1192,14 +1192,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.damage = 1.5f, .lifetime = 1.5f, .range = 115, .speed = 500}, {.normal = {.damage = 1.75f, .lifetime = 1.5f, .range = 115, .speed = 500},
.charged = {.area = 40, .damage = 30, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, .charged = {.area = 40, .damage = 31, .lunge = -110, .range = 160, .scale = 1, .speed = 300},
.cooldown = 0.08f}, .cooldown = 0.08f},
{.normal = {.damage = 2.25f, .lifetime = 2.25f, .range = 140, .speed = 650}, {.normal = {.damage = 2.5f, .lifetime = 2.25f, .range = 140, .speed = 650},
.charged = {.area = 55, .damage = 42, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, .charged = {.area = 55, .damage = 43, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300},
.cooldown = 0.08f}, .cooldown = 0.08f},
{.normal = {.damage = 3, .lifetime = 3, .range = 165, .speed = 800}, {.normal = {.damage = 3.25f, .lifetime = 3, .range = 165, .speed = 800},
.charged = {.area = 70, .damage = 54, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, .charged = {.area = 70, .damage = 56, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300},
.cooldown = 0.08f}, .cooldown = 0.08f},
}, },
.bot = {.rangeMax = 110}, .bot = {.rangeMax = 110},
@@ -1212,18 +1212,36 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.flavor = "Who ya gonna call?", .flavor = "Who ya gonna call?",
.hit = HIT_AIR, .hit = HIT_AIR,
.kick = -0.1f, .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}, .normal = {.label = "suck",
.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}, .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 = .levels =
{ {
{.normal = {.damage = 2, .range = 110, .speed = 160}, {.normal = {.damage = 1.75f, .range = 110, .speed = 160},
.charged = {.damage = 6, .lifetime = 0.2f, .lunge = -120, .range = 110, .speed = 2100}, .charged = {.damage = 7.25f, .lifetime = 0.2f, .lunge = -120, .range = 110, .speed = 2100},
.cooldown = 0.2f}, .cooldown = 0.2f},
{.normal = {.damage = 3, .range = 135, .speed = 190}, {.normal = {.damage = 2.75f, .range = 135, .speed = 190},
.charged = {.damage = 9, .lifetime = 0.2f, .lunge = -120, .range = 135, .speed = 2600}, .charged = {.damage = 11, .lifetime = 0.2f, .lunge = -120, .range = 135, .speed = 2600},
.cooldown = 0.2f}, .cooldown = 0.2f},
{.normal = {.damage = 5, .range = 160, .speed = 220}, {.normal = {.damage = 4.75f, .range = 160, .speed = 220},
.charged = {.damage = 15, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100}, .charged = {.damage = 17, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100},
.cooldown = 0.2f}, .cooldown = 0.2f},
}, },
.bot = {.rangeMax = 110, .charge = CHARGE_ALWAYS}, .bot = {.rangeMax = 110, .charge = CHARGE_ALWAYS},
@@ -1262,17 +1280,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.shake = 0.15f}, .shake = 0.15f},
.levels = .levels =
{ {
{.normal = {.damage = 12, .range = 320, .speed = 248, .volley = 1}, {.normal = {.damage = 14, .range = 320, .speed = 248, .volley = 1},
.charged = {.area = 30, .damage = 20, .range = 220}, .charged = {.area = 30, .damage = 27, .range = 220},
.cooldown = 0.7f, .cooldown = 0.6f,
.limit = 3}, .limit = 3},
{.normal = {.damage = 14, .range = 320, .speed = 263, .volley = 2}, {.normal = {.damage = 16, .range = 320, .speed = 263, .volley = 2},
.charged = {.area = 40, .damage = 30, .range = 250}, .charged = {.area = 40, .damage = 39, .range = 250},
.cooldown = 0.7f, .cooldown = 0.6f,
.limit = 6}, .limit = 6},
{.normal = {.damage = 15, .range = 320, .speed = 278, .volley = 3}, {.normal = {.damage = 18, .range = 320, .speed = 278, .volley = 3},
.charged = {.area = 50, .damage = 40, .range = 280}, .charged = {.area = 50, .damage = 50, .range = 280},
.cooldown = 0.7f, .cooldown = 0.6f,
.limit = 9}, .limit = 9},
}, },
.bot = {.rangeMin = 60, .rangeMax = 260}, .bot = {.rangeMin = 60, .rangeMax = 260},
@@ -1432,6 +1450,7 @@ float blast_area_get(Attack& attack);
float strength_get(Attack& attack, Vec2 point); float strength_get(Attack& attack, Vec2 point);
float fill_get(float strength); float fill_get(float strength);
std::vector<Vec2> lash_get(Attack& attack); std::vector<Vec2> lash_get(Attack& attack);
Vec2 muzzle_get(Attack& attack);
int shapes_get(Sim& sim, Attack& attack, Capsule* shapes); 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 // One hit's worth of damage and the feedback that goes with it. Source is where it came from, which a fighter gets