From b96defa1e690079e43e1b37b14118c937f50e57f Mon Sep 17 00:00:00 2001 From: shweet Date: Tue, 10 Nov 2026 18:32:31 -0500 Subject: [PATCH] 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. --- resources/gfx/weapons.anm2 | 15 +- resources/sfx/SOURCES.md | 5 +- resources/sfx/countdown.ogg | Bin 0 -> 5979 bytes src/audio.cpp | 1 + src/audio.h | 1 + src/chat.cpp | 5 +- src/effects.cpp | 6 +- src/effects.h | 6 +- src/fighter.cpp | 8 + src/fighter.h | 6 + src/game.h | 2 + src/input.cpp | 16 +- src/input.h | 3 + src/main.cpp | 37 ++++- src/match.cpp | 6 +- src/menu.cpp | 235 +++++++++++++++++----------- src/menu.h | 25 +++ src/protocol.h | 2 +- src/scene.cpp | 72 +++++++-- src/scene.h | 4 +- src/session.cpp | 2 +- src/settings.cpp | 272 +++++++++++++++++++++++++++----- src/settings.h | 25 ++- src/sim.cmake | 38 +++++ src/sim.h | 2 +- src/stats.cpp | 123 +++++++++++++++ src/stats.h | 51 ++++++ src/trinket.h | 10 ++ src/ui.cpp | 299 +++++++++++++++++++++++++++++------- src/ui.h | 30 +++- src/weapon.cpp | 168 ++++++++++++++------ src/weapon.h | 289 ++++++++++++++++++---------------- 32 files changed, 1368 insertions(+), 396 deletions(-) create mode 100644 resources/sfx/countdown.ogg create mode 100644 src/stats.cpp create mode 100644 src/stats.h diff --git a/resources/gfx/weapons.anm2 b/resources/gfx/weapons.anm2 index a94bab4..5890082 100644 --- a/resources/gfx/weapons.anm2 +++ b/resources/gfx/weapons.anm2 @@ -46,6 +46,9 @@ "layers": [ {"name": "sprite"} ], + "nulls": [ + {"name": "Muzzle"} + ], "defaultAnimation": "Fist", "animations": { "Fist": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, @@ -62,12 +65,12 @@ "Bow": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, "Bow Charge": {"length": 4, "loop": false, "layers": [{"layer": 0, "frames": [{"region": 12}, {"region": 13}, {"region": 14}, {"region": 15}]}]}, "Bow Cooldown": {"length": 4, "loop": false, "layers": [{"layer": 0, "frames": [{"region": 16}, {"region": 17}, {"region": 18}, {"region": 19}]}]}, - "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, - "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, - "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, - "Bolter": {"length": 25, "layers": [{"layer": 0, "frames": [{"region": 24, "delay": 5}, {"region": 25, "delay": 5}, {"region": 26, "delay": 5}, {"region": 27, "delay": 5}, {"region": 28, "delay": 5}]}]}, - "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, - "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, + "Blaster": {"layers": [{"layer": 0, "frames": [{"region": 20}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -3.5]}]}]}, + "Uzi": {"layers": [{"layer": 0, "frames": [{"region": 21}]}], "nulls": [{"null": 0, "frames": [{"pos": [14, -2.5]}]}]}, + "Shotgun": {"layers": [{"layer": 0, "frames": [{"region": 22}]}], "nulls": [{"null": 0, "frames": [{"pos": [23, -1.5]}]}]}, + "Bolter": {"length": 25, "layers": [{"layer": 0, "frames": [{"region": 24, "delay": 5}, {"region": 25, "delay": 5}, {"region": 26, "delay": 5}, {"region": 27, "delay": 5}, {"region": 28, "delay": 5}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, 0]}]}]}, + "Rocket Launcher": {"layers": [{"layer": 0, "frames": [{"region": 29}]}], "nulls": [{"null": 0, "frames": [{"pos": [21, 0]}]}]}, + "Grenade Launcher": {"layers": [{"layer": 0, "frames": [{"region": 30}]}], "nulls": [{"null": 0, "frames": [{"pos": [19, -0.5]}]}]}, "Mine": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, "Flamethrower": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, "Vacuum": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, diff --git a/resources/sfx/SOURCES.md b/resources/sfx/SOURCES.md index dc7f5ed..7eab8e4 100644 --- a/resources/sfx/SOURCES.md +++ b/resources/sfx/SOURCES.md @@ -5,7 +5,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | Sound | Source | From | Speed | Rate | BRR bytes | |---|---|---|---|---|---| | beam | SpaceZap 4001_88_7 | 1.09s | 1x | 11025 | 2727 | -| beep | synth beeps | | 1x | 11025 | 315 | +| beep | synth beeps | | 1x | 11025 | 360 | | blast | synth pew | | 1x | 11025 | 1053 | | bolter | US03-11-1 ELECTRICITY - SPARK GAP CRACKLE AND HUM | 7.90s | 1x | 11025 | 1791 | | bolter_heavy | US03-11-1 ELECTRICITY - SPARK GAP CRACKLE AND HUM | 7.90s | 0.7x | 11025 | 2556 | @@ -18,6 +18,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | cast | synth ember | | 1x | 11025 | 2556 | | charge | synth warp | | 1x | 11025 | 5076 | | chat | synth beeps | | 1x | 11025 | 576 | +| countdown | US03-58-2 SPACE, DOOR - DOOR LOCK BEEP | 0.16s | 1x | 11025 | 1557 | | crawl | kept from the synth set | | | | | | dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 | | death | US03-34-1 KICK, HUMAN - SINGLE KICK INTO STOMACH | 0.17s | 1x | 11025 | 2916 | @@ -117,4 +118,4 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | whip_heavy | kept from the demo1 (its whip) set | | | | | | wisp | US02-45-5 ARCHERY - SHOOT FLAMING ARROW | 0.18s | 1x | 11025 | 1584 | -The built sounds come to 171 KB as BRR. +The built sounds come to 172 KB as BRR. diff --git a/resources/sfx/countdown.ogg b/resources/sfx/countdown.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d8d1fbfb1791b83c66127d30b8759e2bde09a3e1 GIT binary patch literal 5979 zcmb_gd0bOR*PetetOi63F=zrr2m&PtzJOZ@$P!T@h+e zIM^GY!Jk(_&)_5?t_V+QN3BF1I~>gpferxXXNZ8whN+7ggT?~NbI@Bv5% zAz`;?w7bPjA-4>-yFY4{2&Kow;QlC8GL+KoXR@Nxsf(yqGzHO%*L6*es=z@>NPto$ zB1KZKWe4`rZ0RT&j(sj+$-P%C7eb-nMqq#>3h5=xpo?Eie zEmh>39u!)%`ednTL8)0yV`yKa`R&H1*DdAU-2m|P((O8;t0M0g0073{G_}kweabgW zw5Ldgmt2bmaR4YnJq_0e-EFUo@62zw^ZI|@i^CED012i6IE61Ez>x6zPM4TE`_%O4 z1Z^h+`k+&lC~<<4MCJ^8*E(WIr(SOS{pZ-1iy^=Qa8TDSCBEAjL*lV@O`N03iBNiM z?Av!@))+IMh^1dEjJ@3gd&sGPKDH5gSUS{!UlgH$=|(BfW2aKO3EX~aH!)GedbT)w zyF;%qYr8|AaalSuoFdWn_Goyl#}P|ph1|=I6b_#%#r^$dB&Pv7j8)bT$bb{%>B@~p zLwkfpw;+ft1Hu-jv1_!kYnE|umbrWOhTB(=d)tWfwq-t}K9Tp{M*6*td^j5T@NM8v zqk;Q#1IHzi`(^w0-#hx>x7_tOMG%)h1zCr;Te{FqO&**K6u^SGGP~4CHyDp}@}8{K zp}D~)aw|?0RGwQYSAl_ntida;R30|tJ-OwfMHNV}tE*GXtE#KR3d%$O_ZwY8ssTV1 z0E6x*f@>6^I|}hi&M;8{5CL%VB5a8^@6NK>3Vr<1`Oa_qyw)A{-W~ZR2v7yNy15(R z-3zIJLiPstA8oe%A8&+MG=RxR=*o*_GO7k0-KZwP>!pOIyl=IZ(LGoe>jF}6tHs^J z6naKk0i!2_ni@tCd1EM5BEb+LSu~aEB25TrK z@HFVkB!MoYm&nthyJvDV=qeCps+I&`=@e293@MrbKju6*7ex?-1{nGvNlJ9)J7UN} zUAh~EtwEnI6!hCuL;@L|QniKe*i)FDWp6?j3^OSri67l zu+qd5ypfa`FzDV}n9XE_iXkvf$kC!R40r+t!y;F}>MzV5c61dbW-#5!%~?!7IcJQ) zpymqL5f;+>>{?scV-c`u#_#3Zy7C>NDLA+Y8KX)9X9Z0;sE~66Y;&?C4+7AX3Q3g3 zj3A4~=oIlHFvjkeNXEiUtGbXeM7X_tN%L6PGhve+^BkNm`+S2;#uAqZa9ibr^Qp22 z>{@#WxRa$>%<~q_`{3Xevgk+)2=v=Z_r;1OGb1cZalYEGX1k7^jv2z90QuN zkg}X84f4Ue-ki}$Z(>?5!NjxSq%mRjbn!xj9FvLC{ zR473!RT2hOS_sPzg(As?Ze|oHvwU--a$62T~w|eWkeYZC@mg7#4Se z9W2N=M-zP?y@O?{%U8z9Q`QSIMNOf0;-b?Xdy1V0sjB(B9(z|&c9y$pNsi2#Qj(L! znu2-e)mJIVrHAGVhgl&d0Msj}z>j*#P(}`*!EH7^gUN?JLTaRiTrG+S2g$6!ar)t2 z#9e2m!Ek7^})Q6d<;_WDcmhB&ccbQ3XP{(t50* z8OXH&t|<(p%Crk7r!OEyCB}7h#Bbz1&A-(1rzYT0lVyZj`G9@$%|Z zh0upM&ST<5^0-#nFzjJmnasYzY)=>}7<#zg;IzewFgqv`J&sC=KmapFszoO+mk!?6RcxqTYAt1nC=YsUV!ti%M24OunI|3_Vy;lG?A+uQS<1dRo zc0+oZVR;FVZL)BPUY3W13Q}o3D-`a8dEbRNDr7RD1ZxzMj(kYDJVn`j3F5p(moG;w z_f{;yg=->Vh@i>};M#^+%x{ntY=wQHO3U;<=yJ1fxY!n!2^gA!@WIv!85>dIzGwv? zTSWy_Dy|6jb$3`p(6p5EumZ#pJE4Eb?u%^h@giqa)6w zTZ%hFdKiERH!hl@!y;YUFqm>FB@dxyk;4D-?8}_~p56bmqzjJb{{nCu4^C>VaSu)^ zYfnn9RRqIu;d5YmQ+k9~;Q33FgQcrn;^~G}lFO5+7*Y(9R?WU#%uk8|p!?Lu)ZwF6_shhO8|yIU6E zigm&H@lc>eTMMM9yXY{3^>^W;CRK5U8F1~P02S73coq?6K(b+N|F2_bp}dj;(58s& zbZf?P_OK*g4@TS%{N^~Z)PcR$dWvZl6m2-0i4SYgc{ZeWXbvqeWk4T*DWO2XvcS{( z97AnWdWesWyNnGv841G)6&chN_dWxT8cC_@*bp^Iw+zoz%V(;ERof@0Q!A+^zc5ur z$}n@VfJih3ZNRZz16bM7aQ4_)4Dm>a0?=^o@$mP=XlhxbCQycSGvMaoDX@%j#Apl8 z6VIzy7G8VRsc8}uk|;m^#eujbAdZJlNeK$7>v)7Va^fVG-I`P|b`CI>E6~6MJWi5O zhNb!WHTBopdWOa(X6F`wsmHSa|i=P$0p50tK z%nw|-CvxRz=aWB%k6#*BW}F!%KmIiE(5v11LCfag%ahNi?_GFRrtb4nQY7jnEPdMj zdoZ`Cpk&vhk{dgIYy8P5nsEqop!H2k__}Ym9V{sN?B25L$qmkT1{SN#tT*&Fr^GB3 z{^-_*d8T$@e#a}+*U!(s%==h3sA=^LUDbMSLv_6C^QTAdo*IZ8z}E!|e68<2yi!{m zQT^`D<$6Nhj?+E@o#<=y))mQ0PTk*|_u}%U>958j6g{}@O`pe4_+)*k-q@PCo7?!x zx9;+D%ZhHWM%HMIW~a4{^m+U&N*pz~BUHLT-AV~5`V{!Gl@M^ZFd=eY|1G?n?|c-o+0?G@SokjRvHHq z6zjWxZpcKVP{7LE_Oz8EKyP{*+@k!w?&xfTSJ?3y!|h&zWn=7r)ITnkU;(bM2qO*O#8|e@AwPZ=$jD zsLBm>A7+2K|Ga2e^{1qW2os}hW`C5CP;ou(r-P+etYh2Jp$S$fB0b_?b?e3Z-kG?l zyOFXplwI^{zI`U>eD}lqZ%y>FgVtHw1`Hl>=`>CI;Qfa;-U?uCuJ5R8nAuZ9SJ~pD zW8JYNJ=%m8vT$|gM)_K1&Tu{fl|dXZ_{hJ7(H3QQppyOeU2KTg=abcqA~X9-PT;*q z#Ado^aG>{@Qip26(Tou!E&1;9w@07s-`q8*L zvH7t_GSBr#bp#iGptUuoW!_wV$0qWEhFY{9J;foQ>?>PS9R)PsL-HZBpxxh!jyczc zKO6Htbi>7SdAKrXG&9K0MA#1E;R3YV&=zmHy|B~g=Fz>oGoq=&b-fewTXs4V%5)W^ zmAfNFA<^>&)(*xxL^bXU86SX@IH9#-oVp?aH(UC(%nOuVB&P&VwQWCUcYq_so7F&n z8*26{8(nY6ymMTY2B-VOt>JG7;|s4pUHf)!UO4hR_4HkAnpKAzrFT!^+U71diikus zH{9(P*RHt6gFoNXy5Cw27-5LBg*${RT27Br(L@Zu27KOK>4GC6_2=0G6=OO`U`<85 zpjuyBp770VmX>&6Yv?lusru9U9HGREjHa>y@upoM3eGd(4>D?j0;^Dw{S*!;&-S0W zn)`TPQc}oxZ+}^~p;esbdkV;_SD2WuF8_db3nBL8_j2#2Y%-ITZNL7LtxS{rVpF<%O7Jv1LukJ*|t$U}i?_k^A*`3?U$P+Wm ztcp`t+I_3BobWt`?G+Jb`rQ_4TS8}@tMZNuVUcR>LsrUrae?Lb`s!x_gqbdCG~PWwN057g5fHJ$Z!GD-I-r`t z%v*B$%`19O9+qZvFs8?ZyjBBa;&!cY&A;Z0BevSP^P72O-}fStJDG%jmmC^*4(tRcB^rhsTw)ra<5NkmhSE#?$-r&Cz@XG zS5^Sv)%-EIeS!?fwQ*|Tvu`aSGrKh2eZNCpjp!@=9!|kcEFfI_LjlR{Igtj?a662t zv^w(G<;+K)`zsS(MFxJBIHO(AIt1yKOKCPQ;Qs_Ae_!5?I$JmF;ut*};#XlDM) zPXkS0?b8T#49rHpVn~o(yMCKpyqWOY3X(Bznwjl(;_#N+n-lVz@d1}KcA}#NCgHKFs*~_0#TGN2B8}`aaXZSzesF!Y^d*V1#pBAH| zUs6-kw0FPKK`WiOnai=qceX{1ECZegmeSzDyMOlndf?A%kDGY6Y82P>nbHT}conIQ zyTb<7DPLQ?2o3+u= 0 ? game.color_get(fighter, TINT_PLAIN) : WHITE; @@ -144,7 +148,6 @@ void Chat::say(Game& game, int member, const std::string& text) if ((int)lines.size() > CHAT_LINES_MAX) lines.erase(lines.begin()); bubbles[member].push_back({.text = text, .time = now}); - typing[member] = false; game.audio.play(SOUND_CHAT); } diff --git a/src/effects.cpp b/src/effects.cpp index 6afbfbb..68d93bd 100644 --- a/src/effects.cpp +++ b/src/effects.cpp @@ -116,9 +116,10 @@ void Effects::popup_spawn(Vec2 position, const char* text, Color color) effect_add(popups, popup); } -void Effects::flash_spawn(Vec2 position, float angle, float size, Color color) +void Effects::flash_spawn(Flash flash) { - effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size}); + flash.life = FLASH_LIFE; + effect_add(flashes, flash); } // A particle at position, shrinking and fading as its life runs out: an outline in its colour around a core shaded by @@ -207,6 +208,7 @@ void Effects::draw(Game& game) // Each flash is a long streak along the shot crossed by a short one, shrinking as it fades. for (Flash& flash : flashes) { + game.scene.flash_follow(flash); float fade = flash.life / FLASH_LIFE; float length = FLASH_LENGTH * flash.size * fade; float width = FLASH_WIDTH * flash.size * fade; diff --git a/src/effects.h b/src/effects.h index a7c1f6c..9ba1e33 100644 --- a/src/effects.h +++ b/src/effects.h @@ -49,6 +49,10 @@ struct Flash float angle = 0; float life = 0; float size = 0; + // The fighter, hand and weapon that fired it, for keeping it on the muzzle (see Scene::flash_follow). + int owner = 0; + int hand = 0; + int weapon = 0; }; // A fading copy of a sprite left behind by something moving fast, and what its fighter wore (nothing, FASHION_NONE, @@ -66,7 +70,7 @@ class Effects public: void burst(Burst burst, Vec2 position, float angle, Color color); void afterimage_spawn(Pose pose, int worn); - void flash_spawn(Vec2 position, float angle, float size, Color color); + void flash_spawn(Flash flash); void afterimage_draw(Game& game); void number_spawn(Vec2 position, int value, int target, Color color); void dps_record(int amount); diff --git a/src/fighter.cpp b/src/fighter.cpp index a96a921..199a177 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -145,7 +145,15 @@ void Fighter::damage_take(Sim& sim, Damage damage) knock_take(direction * knock, damage.owner); if (health > 0) return; + die(sim); + if (damage.owner != index) + sim.fighters[damage.owner].kills++; +} +// Out of the round in a burst, its health gone and its attacks vanishing with it. +void Fighter::die(Sim& sim) +{ + health = 0; isAlive = false; charge_cancel(sim); for (Attack& attack : sim.weapons.attacks) diff --git a/src/fighter.h b/src/fighter.h index 87581c6..30f71e4 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -123,6 +123,11 @@ public: float scale = 1; float shockCooldown = 0; float contactCooldown = 0; + // Running totals for the profile's stats (see Stats): damage dealt, deathmatch kills and matches won. They're + // never reset while the fighter lasts, so rewinding the game corrects them. + float damageDealt = 0; + int kills = 0; + int wins = 0; int index = 0; int launcher = 0; int slot = 0; @@ -152,6 +157,7 @@ public: void position_set(Sim& sim, Vec2 feet); void slots_set(int weapons, int trinkets); void damage_take(Sim& sim, Damage damage); + void die(Sim& sim); void update(Sim& sim); void blend(float alpha); void impulse_add(Vec2 impulse); diff --git a/src/game.h b/src/game.h index aaf0da8..f0ca785 100644 --- a/src/game.h +++ b/src/game.h @@ -14,6 +14,7 @@ #include "settings.h" #include "sim.h" #include "sprites.h" +#include "stats.h" #include "text.h" #include "ui.h" @@ -48,6 +49,7 @@ struct Game Session session; Sim sim; Sprites sprites; + Stats stats; Text text; Ui ui; Vec2 camera; diff --git a/src/input.cpp b/src/input.cpp index ce60a6d..d493fb6 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -11,7 +11,6 @@ constexpr int BIND_WHEEL_DOWN = BIND_WHEEL_UP + 1; constexpr int BIND_AXIS = 100; constexpr float AXIS_MAX = 32767; constexpr float AXIS_THRESHOLD = 0.5f; -constexpr float AIM_DEADZONE = 0.3f; constexpr float AIM_REACH = 120; constexpr float NAV_THRESHOLD = 0.6f; constexpr Uint64 NAV_DELAY = 400; @@ -308,7 +307,7 @@ void Input::poll(Game& game) if (!device.pad) continue; Vec2 axes = {device.axes[SDL_GAMEPAD_AXIS_RIGHTX], device.axes[SDL_GAMEPAD_AXIS_RIGHTY]}; - if (vec2_length(axes) <= AIM_DEADZONE) + if (vec2_length(axes) <= aimDeadzone) continue; Device& aimer = device.isClaimed ? device : devices[DEVICE_KEYBOARD]; aimer.stick = vec2_normalize(axes); @@ -487,6 +486,19 @@ bool Input::is_pad_binding_down(Device& device, int binding) return (binding - BIND_AXIS) % 2 ? value > AXIS_THRESHOLD : value < -AXIS_THRESHOLD; } +// Rumbles the gamepad playing a device, at a strength from 0 to 1, both motors alike. The keyboard's device rumbles +// whichever gamepads aim for it (those not claimed by a player of their own). +void Input::rumble_play(int device, float strength, Uint32 milliseconds) +{ + Uint16 level = std::clamp(strength, 0.0f, 1.0f) * SDL_MAX_UINT16; + for (int i = 0; i < DEVICE_COUNT; i++) + { + bool isPlaying = i == device || (device == DEVICE_KEYBOARD && !devices[i].isClaimed); + if (devices[i].pad && isPlaying) + SDL_RumbleGamepad(devices[i].pad, level, level, milliseconds); + } +} + // A control's binding as shown in hints: its gamepad side while the keyboard's device aims with a stick, otherwise its // keyboard side. void Input::control_name_get(Control control, char* text, int size) diff --git a/src/input.h b/src/input.h index f3b840e..d19c6d4 100644 --- a/src/input.h +++ b/src/input.h @@ -149,6 +149,8 @@ class Input public: Binding bindings[CONTROL_COUNT]; Device devices[DEVICE_COUNT]; + // How far a right stick must lean before it aims (see Settings::stickDeadzone). + float aimDeadzone = 0.3f; Vec2 mouse; char typed[TYPED_LENGTH] = {}; float scroll = 0; @@ -190,6 +192,7 @@ public: bool is_mouse_released(MouseButton button); void binding_name_get(int binding, BindingSide side, char* text, int size); void control_name_get(Control control, char* text, int size); + void rumble_play(int device, float strength, Uint32 milliseconds); bool is_control_down(int device, Control control); diff --git a/src/main.cpp b/src/main.cpp index f93f3a2..3e3889d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,8 @@ #include #include +// How early a browser frame may come and still go under the frame rate limit. +constexpr Uint64 FRAME_SLACK = 2 * SDL_NS_PER_MS; constexpr int WINDOW_SCALE = 2; constexpr double MAX_FRAME_TIME = 0.25; #ifdef SDL_PLATFORM_EMSCRIPTEN @@ -69,6 +71,7 @@ void game_tick(Game& game) static void presentation_update(Game& game) { game.effects.update(); + game.stats.update(game); game.ui.update(game); game.scene.update(); game.cameraPrevious = game.camera; @@ -122,6 +125,8 @@ struct FrameClock static Game game; static FrameClock frameClock; +// When the next frame is due under the frame rate limit (see frame_wait_get), in nanoseconds. +static Uint64 frameDue = 0; // One frame's input and as many fixed ticks as the time since the last frame allows. static void frame_step() @@ -169,6 +174,28 @@ static void frame_step() game.alpha = frameClock.accumulator / TICK; } +// The frame rate limit in use: none while vsync paces the frames. +static int frame_limit_get() +{ + return game.settings.isVsync ? 0 : game.settings.fpsLimit; +} + +// How long until the frame rate limit lets the next frame go, in nanoseconds. +static Uint64 frame_wait_get() +{ + Uint64 now = SDL_GetTicksNS(); + return frame_limit_get() > 0 && frameDue > now ? frameDue - now : 0; +} + +// Sets when the frame after this one is due under the limit: a limit's worth of time after this one was due, or after +// now if it ran late, so a slow frame doesn't bring on a burst to catch up. +static void frame_due_advance() +{ + int limit = frame_limit_get(); + if (limit > 0) + frameDue = std::max(frameDue, SDL_GetTicksNS()) + SDL_NS_PER_SECOND / limit; +} + // One frame, stepped and drawn. Returns whether to keep going. static bool frame_run() { @@ -178,8 +205,13 @@ static bool frame_run() } #ifdef SDL_PLATFORM_EMSCRIPTEN +// The browser paces the frames itself, so under a frame rate limit those that come too soon are skipped (one that's +// only FRAME_SLACK early still goes, for screens refreshing a shade fast). static void frame_callback() { + if (frame_wait_get() > FRAME_SLACK) + return; + frame_due_advance(); frame_run(); } @@ -276,9 +308,12 @@ int main(int argc, char** argv) }); return 0; #endif - while (frame_run()) + do { + SDL_DelayPrecise(frame_wait_get()); + frame_due_advance(); } + while (frame_run()); game.settings.save(game); game.net.shutdown(); diff --git a/src/match.cpp b/src/match.cpp index c88e85a..6fc0be7 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -187,6 +187,7 @@ void Match::update(Sim& sim) if (winner >= 0 && wins[winner] >= rules.wins_get()) { state = MATCH_OVER; + sim.fighters[winner].wins++; std::fill(std::begin(isReady), std::end(isReady), false); break; } @@ -494,7 +495,7 @@ void Match::fight_start(Sim& sim) } // The round ends with one fighter (or none) left standing, or when time runs out with whoever has the most health, -// drawn if that's shared. +// drawn if that's shared. Everyone else standing when time runs out dies on the spot. void Match::deathmatch_update(Sim& sim) { int alive = 0; @@ -519,6 +520,9 @@ void Match::deathmatch_update(Sim& sim) winner = fighter.health == best ? -1 : i; best = fighter.health; } + for (int i = 0; i < sim.fighterCount; i++) + if (sim.fighters[i].isAlive && sim.fighters[i].health < best) + sim.fighters[i].die(sim); } if (winner >= 0) wins[winner]++; diff --git a/src/menu.cpp b/src/menu.cpp index a6fb86b..b356bd0 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -24,7 +24,9 @@ constexpr float DIM_ALPHA = 0.6f; constexpr float DIVIDER = 2; constexpr float STICK_DEADZONE = 0.3f; constexpr float STICK_SCROLL = 240; -constexpr float HEADER_BUTTON_WIDTH = 92; +// Each header button's longest label, which sizes it so it keeps its width whichever label shows. +constexpr const char* BUTTON_WIDEST[HEADER_BUTTON_COUNT] = {"Ready!", "Are you sure?", "Stop Spectating", + "Are you sure?"}; constexpr float LABEL_WIDTH = 136; constexpr float NUMBER_WIDTH = 84; constexpr float PADDING = 8; @@ -52,11 +54,6 @@ constexpr const char* PICK_NAMES[] = {"Random", "Choice"}; constexpr const char* TIMING_NAMES[] = {"First Round", "Every Round", "Period"}; // The header's tab strip: the whole header, less room on the right for the match's buttons. -static Rect tab_area_get(Game& game, Rect header) -{ - bool isMatch = game.sim.match.state != MATCH_PRACTICE; - return {header.x, header.y, header.w - (isMatch ? HEADER_BUTTON_WIDTH * 2 : 0), header.h}; -} // The Menu control opens and closes it. While open: click a tab to switch to it. On an item tab, click an item to // select it, and click it again to put it in the target slot (then the target moves on to the next empty slot); click @@ -108,6 +105,7 @@ void Menu::update(Game& game) } } isForfeitArmed &= isOpen; + isQuitArmed &= isOpen; int tabs[TAB_COUNT]; int shown = tabs_get(game, tabs); if (std::find(tabs, tabs + shown, tab) == tabs + shown) @@ -120,13 +118,14 @@ void Menu::update(Game& game) Vec2 mouse = input.mouse; bool isLeft = input.is_mouse_pressed(MOUSE_LEFT); bool isRight = input.is_mouse_pressed(MOUSE_RIGHT); - Rect strip = tab_area_get(game, layout.header); - if (isLeft && is_point_in_rect(mouse, strip)) + HeaderLayout header = header_layout_get(game, layout.header); + for (int i = 0; i < header.tabCount && isLeft; i++) { - int clicked = tabs[std::min(shown - 1, (int)((mouse.x - strip.x) / strip.w * shown))]; - if (clicked != tab) + if (!is_point_in_rect(mouse, header.tabCells[i])) + continue; + if (header.tabs[i] != tab) game.audio.play(SOUND_SELECT); - tab = clicked; + tab = header.tabs[i]; } if (tab >= ITEM_TABS) return; @@ -446,75 +445,125 @@ void Menu::about_draw(Game& game, Rect area) } } -// The tab names spread across the strip (big when they fit), and in a match a Forfeit button that asks again before -// leaving, with a Ready button beside it while a round counts down. Spectating, it's a button to stop. +// Lays the header out: the tabs shown, then in a match its buttons (Ready while a round counts down, and Forfeit, or +// one to stop spectating), and on desktop Quit last. Each cell is as wide as its name (a button's longest label) and +// padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab names are big +// when they all fit. +HeaderLayout Menu::header_layout_get(Game& game, Rect area) +{ + Text& text = game.text; + Match& match = game.sim.match; + HeaderLayout header; + header.tabCount = tabs_get(game, header.tabs); + if (game.session.is_watching(game)) + header.buttons[header.buttonCount++] = BUTTON_STOP; + else if (match.state != MATCH_PRACTICE) + { + if (match.state == MATCH_COUNTDOWN) + header.buttons[header.buttonCount++] = BUTTON_READY; + header.buttons[header.buttonCount++] = BUTTON_FORFEIT; + } +#ifndef __EMSCRIPTEN__ + header.buttons[header.buttonCount++] = BUTTON_QUIT; +#endif + + float buttons = 0; + for (int i = 0; i < header.buttonCount; i++) + buttons += text.size_get(BUTTON_WIDEST[header.buttons[i]]).x + PADDING * 2; + auto tabs_measure = [&](float scale) { + float width = 0; + for (int i = 0; i < header.tabCount; i++) + width += text.size_get(TAB_NAMES[header.tabs[i]], FONT_SMALL, scale).x + PADDING * 2; + return width; + }; + header.scale = tabs_measure(TAB_SCALE) + buttons <= area.w ? TAB_SCALE : 1; + float spare = (area.w - tabs_measure(header.scale) - buttons) / (header.tabCount + header.buttonCount); + float x = area.x; + auto cell_next = [&](float width) { + Rect cell = {std::round(x), area.y, std::round(x + width + PADDING * 2 + spare) - std::round(x), area.h}; + x += width + PADDING * 2 + spare; + return cell; + }; + for (int i = 0; i < header.tabCount; i++) + header.tabCells[i] = cell_next(text.size_get(TAB_NAMES[header.tabs[i]], FONT_SMALL, header.scale).x); + for (int i = 0; i < header.buttonCount; i++) + header.buttonCells[i] = cell_next(text.size_get(BUTTON_WIDEST[header.buttons[i]]).x); + return header; +} + +// The header's tabs, divided, and any buttons (see header_layout_get). Forfeit and Quit ask again before going. void Menu::header_draw(Game& game, MenuLayout& layout) { Renderer& renderer = game.renderer; Text& text = game.text; Ui& ui = game.ui; - Rect strip = tab_area_get(game, layout.header); - int tabs[TAB_COUNT]; - int shown = tabs_get(game, tabs); - float width = 0; - for (int i = 0; i < shown; i++) - width += text.size_get(TAB_NAMES[tabs[i]], FONT_SMALL, TAB_SCALE).x + PADDING * 2; - float scale = width <= strip.w ? TAB_SCALE : 1; - for (int i = 0; i < shown; i++) + HeaderLayout header = header_layout_get(game, layout.header); + float scale = header.scale; + for (int i = 0; i < header.tabCount; i++) { // Another tab under the mouse gets a faint box behind its name. - Rect cell = {std::round(strip.x + strip.w * i / shown), strip.y, std::round(strip.w / shown), strip.h}; - bool isHovered = tabs[i] != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); + Rect cell = header.tabCells[i]; + int shown = header.tabs[i]; + bool isHovered = shown != tab && !ui.isFocusShown && is_point_in_rect(game.input.mouse, cell); if (isHovered) renderer.rect_draw({cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}, color_fade(ui.shade, TAB_HOVER_ALPHA)); - const char* name = TAB_NAMES[tabs[i]]; - Vec2 size = text.size_get(name, FONT_SMALL, scale); - float center = strip.x + strip.w * (i * 2 + 1) / (shown * 2); - Vec2 corner = {std::round(center - size.x / 2), std::round(strip.y + (strip.h - size.y) / 2)}; + Vec2 size = text.size_get(TAB_NAMES[shown], FONT_SMALL, scale); + Vec2 corner = {std::round(cell.x + (cell.w - size.x) / 2), std::round(cell.y + (cell.h - size.y) / 2)}; text.draw( - renderer, name, - {.color = tabs[i] == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); + renderer, TAB_NAMES[shown], + {.color = shown == tab || isHovered ? ui.tint : ui.shade, .position = corner, .scale = {scale, scale}}); if (i > 0) - renderer.rect_draw({std::round(strip.x + strip.w * i / shown - DIVIDER / 2), strip.y + PADDING, DIVIDER, - strip.h - PADDING * 2}, - ui.tint); + renderer.rect_draw({cell.x - DIVIDER / 2, cell.y + PADDING, DIVIDER, cell.h - PADDING * 2}, ui.tint); } Match& match = game.sim.match; - if (match.state == MATCH_PRACTICE) - return; - Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING}; - if (game.session.is_watching(game)) + for (int i = 0; i < header.buttonCount; i++) { - button.w += HEADER_BUTTON_WIDTH; - if (ui.button_draw(game, button, "Stop Spectating")) + Rect cell = header.buttonCells[i]; + Rect button = {cell.x + PADDING / 2, cell.y + PADDING / 2, cell.w - PADDING, cell.h - PADDING}; + switch (header.buttons[i]) { - game.session.watch_send(game, 0); - isOpen = false; + case BUTTON_STOP: + if (!ui.button_draw(game, button, "Stop Spectating")) + break; + game.session.watch_send(game, 0); + isOpen = false; + break; + case BUTTON_READY: + { + bool isReady = match.isReady[game.local]; + ui.widgets_enable(!isReady); + game.input.devices[game.devices[game.local]].isReady |= + ui.button_draw(game, button, isReady ? "Ready!" : "Ready", &match.isReady[game.local]); + ui.widgets_enable(true); + break; + } + case BUTTON_FORFEIT: + { + const char* forfeit = match.state == MATCH_OVER ? "Leave" : "Forfeit"; + if (!ui.button_draw(game, button, isForfeitArmed ? "Are you sure?" : forfeit, &isForfeitArmed)) + break; + if (isForfeitArmed && game.session.is_playing_arena(game)) + { + game.session.arena_leave(game); + isOpen = false; + } + else if (isForfeitArmed) + match.sandbox_return(game.sim); + isForfeitArmed = !isForfeitArmed; + break; + } + case BUTTON_QUIT: + if (!ui.button_draw(game, button, isQuitArmed ? "Are you sure?" : "Quit", &isQuitArmed)) + break; + game.input.isQuit |= isQuitArmed; + isQuitArmed = !isQuitArmed; + break; + default: + break; } - return; } - if (match.state == MATCH_COUNTDOWN) - { - bool isReady = match.isReady[game.local]; - ui.widgets_enable(!isReady); - game.input.devices[game.devices[game.local]].isReady |= - ui.button_draw(game, button, isReady ? "Ready!" : "Ready", &match.isReady[game.local]); - ui.widgets_enable(true); - } - button.x += HEADER_BUTTON_WIDTH; - const char* forfeit = match.state == MATCH_OVER ? "Leave" : "Forfeit"; - if (!ui.button_draw(game, button, isForfeitArmed ? "Are you sure?" : forfeit, &isForfeitArmed)) - return; - if (isForfeitArmed && game.session.is_playing_arena(game)) - { - game.session.arena_leave(game); - isOpen = false; - } - else if (isForfeitArmed) - match.sandbox_return(game.sim); - isForfeitArmed = !isForfeitArmed; } // An item tab: the item grid with its scroll bar, the selection's description (scrolling, back to its top for each @@ -782,12 +831,10 @@ void Menu::online_draw(Game& game, Rect area) else if (!net.error.empty()) status_draw(net.error.c_str(), true); - // Where the lobbies are hosted. + // The online settings, as in Settings. ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Online"); y += ROW_HEIGHT; - ui.hint_set(game, {area.x, y, area.w, WIDGET_HEIGHT}, "The server hosting lobbies.\nEmpty it to reset."); - ui.label_draw(game, "Host", {area.x, y}); - game.settings.host_draw(game, {area.x + LABEL_WIDTH, y, area.w - LABEL_WIDTH, WIDGET_HEIGHT}); + game.settings.online_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, LABEL_WIDTH); return; } @@ -855,7 +902,8 @@ void Menu::online_draw(Game& game, Rect area) bool isSelf = member.id == net.selfId; std::string name = session.isActive ? session.name_get(game, member.id) : member.name; const char* tags = member.id == net.hostId ? (isSelf ? " [Host] [You]" : " [Host]") : isSelf ? " [You]" : ""; - float room = rowWidth - MINI_PLAYER_WIDTH - (member.ping >= 0 ? pingWidth : 0); + bool isPinged = member.ping >= 0 && game.settings.isPingShown; + float room = rowWidth - MINI_PLAYER_WIDTH - (isPinged ? pingWidth : 0); SDL_snprintf(line, sizeof(line), "- %s%s", name.c_str(), tags); while (text.size_get(line).x > room && !name.empty()) { @@ -865,7 +913,7 @@ void Menu::online_draw(Game& game, Rect area) Color color = member_color_get(game, member.id); Vec2 corner = {area.x, std::round(y + (MINI_PLAYER_ROW - text.line_height_get(FONT_SMALL)) / 2)}; text.draw(game.renderer, line, {.color = color, .position = corner}); - if (member.ping >= 0) + if (isPinged) ui.ping_draw(game, {corner.x + text.size_get(line).x + PADDING * 2, corner.y}, member.ping, 1); ui.player_draw(game, {area.x + rowWidth - MINI_PLAYER_WIDTH / 2, y + MINI_PLAYER_ROW - 1}, color, 1, worn); } @@ -1227,7 +1275,7 @@ bool Menu::is_bars_open() // Fills tabs with the ones shown, in order, returning how many: every tab in the sandbox, and in a match the item tabs // (to choose from when its countdown lets fighters, and otherwise to look at and level the sandbox's), Play (the -// game's rules) and Settings. +// game's rules) and Settings. Profile and About are the sandbox's alone. int Menu::tabs_get(Game& game, int* tabs) { Match& match = game.sim.match; @@ -1238,7 +1286,7 @@ int Menu::tabs_get(Game& game, int* tabs) tabs[count++] = i; return count; } - for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS, TAB_ABOUT}) + for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS}) tabs[count++] = tab; return count; } @@ -1253,33 +1301,42 @@ bool Menu::is_choosable(Game& game, ItemKind kind) return match.is_picking(kind); } -// Where a hotbar slot sits: closed, the weapon bar is top left and the trinket bar bottom left, each as large as fits -// its column. On an item tab, both sit under the menu either side of its middle, full size. -Rect Menu::slot_rect(ItemKind kind, int index, int count) +// The size a hotbar of count slots takes in the HUD: the largest that keeps it within its column. +static IconSize hud_size_get(int count) { - IconSize size = slot_size_get(count); - float slot = SLOT_SIZES[size]; - float step = slot + SLOT_GAPS[size]; - Vec2 position = {HOTBAR_MARGIN + index * step, - kind == ITEM_TRINKET ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN}; - if (is_bars_open()) - position = {kind == ITEM_TRINKET ? CANVAS_WIDTH / 2.0f + SLOT_GAP + index * step - : CANVAS_WIDTH / 2.0f - SLOT_GAP - (count - index) * step + SLOT_GAP, - BAR_Y}; - return {std::round(position.x), std::round(position.y), slot, slot}; -} - -// The size a hotbar of count slots takes: full in the menu, and in the HUD the largest that keeps it within its column. -IconSize Menu::slot_size_get(int count) -{ - if (is_bars_open()) - return ICON_FULL; for (int size = ICON_FULL; size < ICON_TINY; size++) if (count * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size] <= HUD_COLUMN_WIDTH) return (IconSize)size; return ICON_TINY; } +// Where a hotbar slot sits in the HUD: the weapon bar top left and the trinket bar bottom left, each as large as fits +// its column. +Rect Menu::slot_hud_rect(ItemKind kind, int index, int count) +{ + float slot = SLOT_SIZES[hud_size_get(count)]; + float step = slot + SLOT_GAPS[hud_size_get(count)]; + float y = kind == ITEM_TRINKET ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN; + return {std::round(HOTBAR_MARGIN + index * step), std::round(y), slot, slot}; +} + +// Where a hotbar slot sits now: in the HUD, or on an item tab under the menu either side of its middle, full size. +Rect Menu::slot_rect(ItemKind kind, int index, int count) +{ + if (!is_bars_open()) + return slot_hud_rect(kind, index, count); + float step = SLOT_SIZES[ICON_FULL] + SLOT_GAPS[ICON_FULL]; + float x = kind == ITEM_TRINKET ? CANVAS_WIDTH / 2.0f + SLOT_GAP + index * step + : CANVAS_WIDTH / 2.0f - SLOT_GAP - (count - index) * step + SLOT_GAP; + return {std::round(x), BAR_Y, SLOT_SIZES[ICON_FULL], SLOT_SIZES[ICON_FULL]}; +} + +// The size a hotbar of count slots takes: full in the menu, and in the HUD as hud_size_get says. +IconSize Menu::slot_size_get(int count) +{ + return is_bars_open() ? ICON_FULL : hud_size_get(count); +} + // Lays the panel out: the header across the top, and below it, right to left, the description, divider, scroll bar // and as many grid columns as fit. MenuLayout Menu::layout_get() diff --git a/src/menu.h b/src/menu.h index 590d2a7..d7458b3 100644 --- a/src/menu.h +++ b/src/menu.h @@ -53,6 +53,28 @@ struct MenuLayout int rows = 1; }; +// A button in the menu's header during a match. +enum HeaderButton +{ + BUTTON_READY, + BUTTON_FORFEIT, + BUTTON_STOP, + BUTTON_QUIT, + HEADER_BUTTON_COUNT +}; + +// The header's cells (see Menu::header_layout_get): the tabs shown, then any buttons, and the scale the tab names take. +struct HeaderLayout +{ + Rect buttonCells[HEADER_BUTTON_COUNT]; + Rect tabCells[TAB_COUNT]; + float scale = 1; + int buttonCount = 0; + int buttons[HEADER_BUTTON_COUNT]; + int tabCount = 0; + int tabs[TAB_COUNT]; +}; + // The Tab menu. In the sandbox: browse every weapon or trinket, read about it, level it and assign it to hotbar slots, // set up and start a game from Play, change your name in Profile, and Settings. In a match: Settings and a Forfeit // button (pressed twice), or only the item tabs being chosen from while a round's countdown lets fighters choose @@ -76,6 +98,7 @@ public: void target_fill(Game& game); bool is_bars_open(); Rect slot_rect(ItemKind kind, int index, int count); + Rect slot_hud_rect(ItemKind kind, int index, int count); IconSize slot_size_get(int count); bool is_choosable(Game& game, ItemKind kind); int target_get(Game& game, ItemKind kind); @@ -101,6 +124,7 @@ private: int selections[ITEM_TABS] = {}; int page = PAGE_MAIN; bool isForfeitArmed = false; + bool isQuitArmed = false; bool isMultiplayer = false; // The rules a game queued in a lobby is hosted with (kept apart from this screen's own single player ones). Rules queueRules = rules_get(MODE_DEATHMATCH); @@ -113,6 +137,7 @@ private: MenuLayout layout_get(); Rect box_rect(int index); int tabs_get(Game& game, int* tabs); + HeaderLayout header_layout_get(Game& game, Rect header); int item_count(); int row_count(); void slot_assign(Game& game, int slot); diff --git a/src/protocol.h b/src/protocol.h index 0cc4209..653e4bc 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 5; +constexpr int PROTOCOL_VERSION = 6; constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_CODE_LENGTH = 4; // Letters only, without I and O, so codes are easy to read out and type. diff --git a/src/scene.cpp b/src/scene.cpp index 58683a0..5f6a803 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -6,6 +6,11 @@ #include #include +// Rumble: the damage taken that shakes a gamepad fully, and how long, and the lighter tick for landing a hit. +constexpr float RUMBLE_FULL_DAMAGE = 60; +constexpr Uint32 RUMBLE_HIT_TIME = 180; +constexpr float RUMBLE_LANDED = 0.15f; +constexpr Uint32 RUMBLE_LANDED_TIME = 60; constexpr int BLINK_MILLISECONDS = 80; // How long a claimed shot takes to turn from its old owner's colour to its new one's. @@ -97,6 +102,7 @@ constexpr float TELEPORT_RING_ALPHA = 0.35f; constexpr float WHIP_INNER = 2; constexpr float WHIP_JITTER = 0.5f; constexpr float WHIP_OUTER = 5; +constexpr float WHIP_TAPER = 0.35f; constexpr float WISP_FRAME_STAGGER = 0.3f; constexpr int TOMATO_BLOBS = 6; constexpr float TOMATO_BLOB_MAX = 3.5f; @@ -167,6 +173,8 @@ struct Stroke float outer = 0; float inner = 0; float jitter = 0; + // Its width at the end, as a share of its width at the start. + float taper = 1; }; // A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Jitter @@ -205,10 +213,12 @@ static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) for (size_t i = 0; i + 1 < points.size(); i++) { float strength = base * falloff_at(distances[i] / reach); + float width = math_lerp(1, stroke.taper, distances[i] / distance); if (layer == 0) - game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); + game.renderer.line_draw(points[i], points[i + 1], stroke.outer * width, color_dim(color, strength)); else - game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength))); + game.renderer.line_draw(points[i], points[i + 1], stroke.inner * width, + color_shade(color, fill_get(strength))); } } } @@ -442,10 +452,18 @@ void Scene::shake_add(float amount) game.effects.shake_add(amount); } -void Scene::flash_spawn(Vec2 position, float angle, float size, int owner) +// A shot's muzzle flash, which keeps to the muzzle of the weapon that fired it (see flash_follow). +void Scene::flash_spawn(Attack& attack, float size) { - if (!isMuted) - game.effects.flash_spawn(position, angle, size, game.color_get(owner)); + if (isMuted) + return; + game.effects.flash_spawn({.color = game.color_get(attack.owner), + .position = muzzle_get(attack), + .angle = attack.angle, + .size = size, + .owner = attack.owner, + .hand = attack.hand, + .weapon = attack.weapon}); } void Scene::fighter_afterimage_spawn(Fighter& fighter) @@ -477,12 +495,22 @@ void Scene::attack_aura_spawn(Attack& attack) tint_get(game, attack)); } +// A hit's number (unless they're turned off) and its share of the DPS meter, and with rumble on, a shake of this +// screen's gamepad: harder for more damage taken, and a light tick for a hit landed. void Scene::number_spawn(Damage& damage) { if (isMuted) return; - game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); + if (game.settings.isDamageShown) + game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); game.effects.dps_record(damage.amount); + if (!game.settings.isRumbling) + return; + int device = game.devices[game.local]; + if (damage.target == game.local) + game.input.rumble_play(device, damage.amount / RUMBLE_FULL_DAMAGE, RUMBLE_HIT_TIME); + else if (damage.owner == game.local) + game.input.rumble_play(device, RUMBLE_LANDED, RUMBLE_LANDED_TIME); } // A level going up or down chimes, and outside the menu pops up over this screen's fighter. @@ -880,17 +908,18 @@ void Scene::attacks_draw() } else if (kind == ATTACK_WHIP) { - // A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite - // rides the end of the whip, pointing the way it runs. + // A plain lash keeps its full width while it unfurls, then thins away after the crack, slowly at first. It + // tapers toward its tip. The tip sprite rides the end of the whip, pointing the way it runs. Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip))); float fade = attack.is_charged() ? progress : std::max(0.0f, (progress - PATH_OUT) / (1 - PATH_OUT)); - float width = (1 - fade) * attack.scale; + float width = (1 - (attack.is_charged() ? fade : fade * fade)) * attack.scale; std::vector points = attack.is_charged() ? std::vector{attack.position, attack.tip} : lash_get(attack); Vec2 end = points.back(); Vec2 before = points[points.size() - 2]; strength_line_draw(game, attack, - {points, WHIP_OUTER * width, WHIP_INNER * width, attack.is_charged() ? 0 : WHIP_JITTER}); + {points, WHIP_OUTER * width, WHIP_INNER * width, attack.is_charged() ? 0 : WHIP_JITTER, + attack.is_charged() ? 1 : WHIP_TAPER}); game.sprites.effects.draw(renderer, "Whip Tip", 0, {.color = color_fade(tint, 1 - fade), .position = end + shake, @@ -1205,6 +1234,26 @@ void Scene::held_draw(Fighter& fighter) hand_draw(fighter, held, 0, draw); } +// Keeps a muzzle flash on the muzzle of the weapon that fired it as that weapon moves after, as it's drawn (the main +// hand's kicking back with its recoil). Once the fighter is gone or that hand holds something else, it stays put. +void Scene::flash_follow(Flash& flash) +{ + if (flash.owner >= game.sim.fighterCount) + return; + Fighter& fighter = game.sim.fighters[flash.owner]; + int slot = fighter.hand_slot_get(flash.hand); + if (!fighter.isAlive || slot < 0 || fighter.weaponLoadout[slot] != flash.weapon) + return; + float side = fighter.is_left() ? -1 : 1; + float kick = flash.hand == 0 ? kick_get(fighter) * side : 0; + Attack probe = {.origin = fighter.hand_get(flash.hand), + .angle = vec2_angle(fighter.aim) + kick, + .weapon = flash.weapon, + .isLeft = fighter.is_left()}; + flash.position = muzzle_get(probe); + flash.angle = probe.angle; +} + // Dual Wield: the off-hand weapons, a darker shade of the fighter's colour, fanned out behind it (see // Fighter::hand_get), the furthest back first so the nearer cover them. Each shakes and reloads like the main one. void Scene::offhand_draw(Fighter& fighter) @@ -1244,8 +1293,7 @@ void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw) float frame = SDL_GetTicks() * art.fps / SDL_MS_PER_SECOND; if (tether) { - Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; - draw.angle = vec2_angle(end - draw.position); + draw.angle = vec2_angle(tether->position - draw.position); animation += " Base"; } else if (charge) diff --git a/src/scene.h b/src/scene.h index 61d9779..31d48d7 100644 --- a/src/scene.h +++ b/src/scene.h @@ -3,6 +3,7 @@ #include "anm2.h" #include "sim.h" +struct Flash; struct Game; // Shows the game this screen is on: draws its world, fighters and attacks, animates its fighters, and plays the cues it @@ -28,7 +29,8 @@ public: void burst_spawn(Burst burst, Vec2 position, float angle, int owner) override; void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) override; void shake_add(float amount) override; - void flash_spawn(Vec2 position, float angle, float size, int owner) override; + void flash_spawn(Attack& attack, float size) override; + void flash_follow(Flash& flash); void fighter_afterimage_spawn(Fighter& fighter) override; void attack_afterimage_spawn(Attack& attack) override; void fighter_aura_spawn(Fighter& fighter) override; diff --git a/src/session.cpp b/src/session.cpp index a3e4c86..5b44159 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -51,7 +51,7 @@ void Session::start(Game& game) game.chat.players_clear(); bool isMaker = game.net.members.size() <= 1; game.chat.system((isMaker ? "Created lobby " : "Joined lobby ") + game.net.code + - ". Press T to chat, or type /help for commands."); + ". Type /help in chat for commands."); } // Back to playing alone: this screen's fighter (if it had one) stays, in a sandbox of its own. diff --git a/src/settings.cpp b/src/settings.cpp index c7cbd17..a8410bd 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -36,6 +36,7 @@ constexpr const char* KEY_TRINKET_SLOTS = "trinket_slots"; constexpr const char* KEY_VOLUME = "volume"; constexpr const char* KEY_SHAKE = "screenshake"; constexpr const char* KEY_HUD_AIM = "hud_aim"; +constexpr const char* KEY_HINTS_LEARNED = "hints_learned"; constexpr const char* KEY_LOADOUT = "loadout_"; constexpr const char* KEY_SERVER_URL = "server_url"; constexpr const char* KEY_RULES_OPEN = "rules_open"; @@ -43,6 +44,17 @@ constexpr float FOOTER_GAP = 4; constexpr float LOADOUT_GAP = 4; constexpr const char* KEY_HUD_DPS = "hud_dps"; constexpr const char* KEY_HUD_SPEED = "hud_speed"; +constexpr const char* KEY_HUD_FPS = "hud_fps"; +constexpr const char* KEY_FPS_LIMIT = "fps_limit"; +constexpr const char* KEY_MUTE_UNFOCUSED = "mute_unfocused"; +constexpr const char* KEY_DAMAGE_NUMBERS = "damage_numbers"; +constexpr const char* KEY_RUMBLE = "rumble"; +constexpr const char* KEY_STICK_DEADZONE = "stick_deadzone"; +constexpr const char* KEY_WINDOW_SCALE = "window_scale"; +constexpr const char* KEY_CHAT = "chat"; +constexpr const char* KEY_PING = "ping"; +constexpr int DEADZONE_MAX = 60; +constexpr int DEADZONE_STEP = 5; constexpr const char* KEY_VSYNC = "vsync"; constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots"; constexpr const char* SETTINGS_FILE = "settings.txt"; @@ -57,6 +69,10 @@ constexpr const char* KEY_SANDBOX_Y = "sandbox_y"; constexpr const char* SANDBOX_START_WEAPON = "Sword"; constexpr float CLEAR_PROBE = 4; constexpr Uint64 WEB_SAVE_INTERVAL = 5000; +constexpr float STATS_COLUMN_WIDTH = 80; +constexpr const char* PLAY_NAMES[PLAY_KIND_COUNT] = {"Single Player", "Multiplayer"}; +constexpr const char* RANK_LABELS[ITEM_KIND_COUNT][RANK_ORDER_COUNT] = {{"Top Weapons", "Bottom Weapons"}, + {"Top Trinkets", "Bottom Trinkets"}}; static std::string file_path_get() { @@ -165,16 +181,6 @@ static int rules_write(Rules& rules, char* text, int size) return length; } -// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save. -static int item_find(ItemKind kind, const char* name) -{ - int count = item_count_get(kind); - for (int i = 0; i < count; i++) - if (SDL_strcmp(name, item_name_get(kind, i)) == 0) - return i; - return -1; -} - // Reads a comma-separated list: a loadout of item names (empty for an empty slot), each with its copy's level as // "name:level" (into levels by slot), or item levels as "name:level" (into levels by item). Returns how many entries // it read. @@ -268,6 +274,17 @@ static int sandbox_write(Fighter& fighter, char* text, int size) return length; } +// Steps value on to the next of a button's options when it's clicked, round to the first after the last. Returns +// whether it did. +template static bool cycle_step(bool isClicked, int& value, const int (&options)[COUNT]) +{ + if (!isClicked) + return false; + int index = std::find(std::begin(options), std::end(options), value) - std::begin(options); + value = options[(index + 1) % COUNT]; + return true; +} + Settings::Settings() { SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); @@ -302,6 +319,13 @@ void Settings::load(Game& game) SDL_strlcpy(serverUrl, value, sizeof(serverUrl)); if (SDL_strcmp(line, KEY_RULES_OPEN) == 0) isRulesOpen = number; + for (int i = 0; i < HINT_COUNT && SDL_strcmp(line, KEY_HINTS_LEARNED) == 0; i++) + { + const char* found = SDL_strstr(value, HINT_KEYS[i]); + size_t length = SDL_strlen(HINT_KEYS[i]); + bool isWhole = found && (found == value || found[-1] == ',') && (found[length] == ',' || !found[length]); + hintsLearned |= isWhole ? 1 << i : 0; + } if (SDL_strcmp(line, KEY_VOLUME) == 0) volume = std::clamp(number / PERCENT, 0.0f, 1.0f); if (SDL_strcmp(line, KEY_MUTE) == 0) @@ -314,6 +338,26 @@ void Settings::load(Game& game) isShaking = number; if (SDL_strcmp(line, KEY_HUD_DPS) == 0) isDpsShown = number; + if (SDL_strcmp(line, KEY_HUD_FPS) == 0) + isFpsShown = number; + if (SDL_strcmp(line, KEY_MUTE_UNFOCUSED) == 0) + isMutedUnfocused = number; + if (SDL_strcmp(line, KEY_DAMAGE_NUMBERS) == 0) + isDamageShown = number; + if (SDL_strcmp(line, KEY_RUMBLE) == 0) + isRumbling = number; + if (SDL_strcmp(line, KEY_STICK_DEADZONE) == 0) + stickDeadzone = std::clamp(number, 0, DEADZONE_MAX); + if (SDL_strcmp(line, KEY_CHAT) == 0) + isChatShown = number; + if (SDL_strcmp(line, KEY_PING) == 0) + isPingShown = number; + if (SDL_strcmp(line, KEY_WINDOW_SCALE) == 0 && + std::find(std::begin(WINDOW_SCALES), std::end(WINDOW_SCALES), number) != std::end(WINDOW_SCALES)) + windowScale = number; + if (SDL_strcmp(line, KEY_FPS_LIMIT) == 0 && + std::find(std::begin(FPS_LIMITS), std::end(FPS_LIMITS), number) != std::end(FPS_LIMITS)) + fpsLimit = number; if (SDL_strcmp(line, KEY_HUD_SPEED) == 0) isSpeedShown = number; if (SDL_strcmp(line, KEY_HUD_AIM) == 0) @@ -329,6 +373,7 @@ void Settings::load(Game& game) if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0) trinketSlots = std::clamp(number, 1, SLOT_MAX); rules_read(game.sim.match.rules, line, value); + game.stats.read(line, value); sandbox_read(sandbox, line, value); for (int i = 0; i < LOADOUT_COUNT; i++) { @@ -365,7 +410,16 @@ void Settings::save(Game& game) KEY_TRINKET_SLOTS, trinketSlots, KEY_COLLISION, isCollisionShown, KEY_SHAKE, isShaking, KEY_HUD_DPS, isDpsShown, KEY_HUD_SPEED, isSpeedShown, KEY_HUD_AIM, isAimShown, KEY_SERVER_URL, serverUrl, KEY_RULES_OPEN, isRulesOpen); - length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n", KEY_FASHION, fashion); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n%s=%d\n", + KEY_MUTE_UNFOCUSED, isMutedUnfocused, KEY_DAMAGE_NUMBERS, isDamageShown, KEY_RUMBLE, + isRumbling, KEY_STICK_DEADZONE, stickDeadzone, KEY_WINDOW_SCALE, windowScale, KEY_CHAT, + isChatShown, KEY_PING, isPingShown); + length += SDL_snprintf(text + length, sizeof(text) - length, "%s=%d\n%s=%d\n%s=%d\n%s=", KEY_HUD_FPS, isFpsShown, + KEY_FPS_LIMIT, fpsLimit, KEY_FASHION, fashion, KEY_HINTS_LEARNED); + for (int i = 0, gap = 0; i < HINT_COUNT; i++) + if (hintsLearned & 1 << i) + length += SDL_snprintf(text + length, sizeof(text) - length, gap++ ? ",%s" : "%s", HINT_KEYS[i]); + length += SDL_snprintf(text + length, sizeof(text) - length, "\n"); for (int i = 0; i < CONTROL_COUNT; i++) { Binding binding = game.input.bindings[i]; @@ -376,6 +430,7 @@ void Settings::save(Game& game) length += rules_write(match.rules, text + length, sizeof(text) - length); length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length, sizeof(text) - length); + length += game.stats.write(text + length, sizeof(text) - length); for (int i = 0; i < LOADOUT_COUNT; i++) { Loadout& loadout = loadouts[i]; @@ -401,9 +456,9 @@ void Settings::save(Game& game) void Settings::apply(Game& game) { - fullscreen_apply(game); + window_apply(game); game.renderer.vsync_set(isVsync); - game.audio.gain_set(isMuted ? 0 : volume); + game.input.aimDeadzone = stickDeadzone / PERCENT; if (game.sim.match.state == MATCH_PRACTICE) { Fighter& fighter = game.local_get(); @@ -411,9 +466,21 @@ void Settings::apply(Game& game) } } -// Puts the window in or out of fullscreen to match the setting. The web asks the browser to fill the screen instead, -// since SDL's own fullscreen there snaps the canvas into a corner. -void Settings::fullscreen_apply(Game& game) +// Sets the sound's loudness when it changes: the volume, or silence while muted (or, with Mute on Unfocus, while the +// window isn't focused). +void Settings::audio_apply(Game& game) +{ + bool isUnfocused = !(SDL_GetWindowFlags(game.window) & SDL_WINDOW_INPUT_FOCUS); + float gain = isMuted || (isMutedUnfocused && isUnfocused) ? 0 : volume; + if (gain != appliedGain) + game.audio.gain_set(gain); + appliedGain = gain; +} + +// Puts the window in or out of fullscreen to match the setting, and windowed, sizes it to its scale (fixed) or lets it +// be resized (Free). The web asks the browser to fill the screen instead, since SDL's own fullscreen there snaps the +// canvas into a corner, and the page sizes it. +void Settings::window_apply(Game& game) { #ifdef __EMSCRIPTEN__ EM_ASM( @@ -426,6 +493,9 @@ void Settings::fullscreen_apply(Game& game) isFullscreen); #else SDL_SetWindowFullscreen(game.window, isFullscreen); + SDL_SetWindowResizable(game.window, windowScale == 0); + if (!isFullscreen && windowScale > 0) + SDL_SetWindowSize(game.window, CANVAS_WIDTH * windowScale, CANVAS_HEIGHT * windowScale); #endif } @@ -433,7 +503,7 @@ void Settings::fullscreen_apply(Game& game) void Settings::fullscreen_toggle(Game& game) { isFullscreen = !isFullscreen; - fullscreen_apply(game); + window_apply(game); save(game); } @@ -495,34 +565,57 @@ void Settings::draw(Game& game, Rect area, Page& page) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Window"); bool isFullscreenChanged = false; bool isVsyncChanged = false; - if (ui.row_get(page, ROW_HEIGHT, y)) - { - ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Fills the whole screen."); - ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, - "Matches frames to your\nscreen, stopping tearing."); - isFullscreenChanged = ui.toggle_draw(game, {x, y}, "Fullscreen", isFullscreen); - isVsyncChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Vsync", isVsync); - } bool isShakeChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) { - ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shakes the screen on\nbig hits and blasts."); - isShakeChanged = ui.toggle_draw(game, {x, y}, "Screenshake", isShaking); + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Fills the whole screen."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, "Shakes the screen on\nbig hits and blasts."); + isFullscreenChanged = ui.toggle_draw(game, {x, y}, "Fullscreen", isFullscreen); + isShakeChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Screenshake", isShaking); +#ifndef __EMSCRIPTEN__ + // The window's scale, stepping through WINDOW_SCALES, unused in fullscreen. + Rect scale = {x + TOGGLE_SPACING * 2, y, NUMBER_WIDTH, WIDGET_HEIGHT}; + ui.hint_set(game, scale, "The window's size, in\nwhole steps, or Free\nto drag it to any size."); + char label[LINE_LENGTH]; + SDL_snprintf(label, sizeof(label), windowScale > 0 ? "%dx Scale" : "Free Scale", windowScale); + ui.widgets_enable(!isFullscreen); + if (cycle_step(ui.button_draw(game, scale, label) && !isFullscreen, windowScale, WINDOW_SCALES)) + isFullscreenChanged = true; + ui.widgets_enable(true); +#endif + } + + // The frame rate: its meter, the limit (stepping through FPS_LIMITS, unused while vsync paces the frames) and + // vsync. + bool isHudChanged = false; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + Rect limit = {x + TOGGLE_SPACING, y, NUMBER_WIDTH, WIDGET_HEIGHT}; + ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shows how many frames\ndraw each second."); + ui.hint_set(game, limit, "The most frames drawn\neach second, unless\nVsync is on."); + ui.hint_set(game, {x + TOGGLE_SPACING * 2, y, TOGGLE_SPACING, row.h}, + "Matches frames to your\nscreen, stopping tearing."); + isHudChanged |= ui.toggle_draw(game, {x, y}, "FPS", isFpsShown); + char label[LINE_LENGTH]; + SDL_snprintf(label, sizeof(label), fpsLimit > 0 ? "%d FPS" : "Unlimited", fpsLimit); + ui.widgets_enable(!isVsync); + isDirty |= cycle_step(ui.button_draw(game, limit, label) && !isVsync, fpsLimit, FPS_LIMITS); + ui.widgets_enable(true); + isVsyncChanged = ui.toggle_draw(game, {x + TOGGLE_SPACING * 2, y}, "Vsync", isVsync); } if (isFullscreenChanged) - fullscreen_apply(game); + window_apply(game); if (isVsyncChanged) game.renderer.vsync_set(isVsync); if (ui.row_get(page, ROW_HEIGHT, y)) ui.separator_draw(game, {row.x, y, row.w, row.h}, "HUD / Gameplay"); - bool isHudChanged = false; if (ui.row_get(page, ROW_HEIGHT, y)) { ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Shows the damage you\ndeal each second."); ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING, row.h}, "Shows how fast you move."); - isHudChanged |= ui.toggle_draw(game, {x, y}, "DPS Meter", isDpsShown); - isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed Meter", isSpeedShown); + isHudChanged |= ui.toggle_draw(game, {x, y}, "DPS", isDpsShown); + isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Speed", isSpeedShown); } if (ui.row_get(page, ROW_HEIGHT, y)) { @@ -531,6 +624,11 @@ void Settings::draw(Game& game, Rect area, Page& page) isHudChanged |= ui.toggle_draw(game, {x, y}, "Aim Preview", isAimShown); isHudChanged |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Display Collision", isCollisionShown); } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {x, y, TOGGLE_SPACING * 2, row.h}, "Pops up the damage\nof each hit."); + isHudChanged |= ui.toggle_draw(game, {x, y}, "Damage Numbers", isDamageShown); + } if (ui.row_get(page, ROW_HEIGHT, y)) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Audio"); @@ -547,10 +645,16 @@ void Settings::draw(Game& game, Rect area, Page& page) if (ui.row_get(page, ROW_HEIGHT, y)) { ui.hint_set(game, {x, y, TOGGLE_SPACING, row.h}, "Silences all sound."); + ui.hint_set(game, {x + TOGGLE_SPACING, y, TOGGLE_SPACING * 2, row.h}, + "Silences all sound while\nthe game isn't focused."); isSound |= ui.toggle_draw(game, {x, y}, "Mute", isMuted); + isSound |= ui.toggle_draw(game, {x + TOGGLE_SPACING, y}, "Mute on Unfocus", isMutedUnfocused); } - if (isSound) - game.audio.gain_set(isMuted ? 0 : volume); + + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, {row.x, y, row.w, row.h}, "Online"); + if (ui.row_get(page, ROW_HEIGHT * 2, y)) + online_draw(game, {area.x, y, row.w, WIDGET_HEIGHT}, LABEL_WIDTH); controls_draw(game, page, row); ui.page_end(game, page); @@ -632,6 +736,25 @@ void Settings::controls_draw(Game& game, Page& page, Rect row) } } } + + // How the gamepad feels: rumble, and how far its aim stick must lean to aim. + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {keyX, y, TOGGLE_SPACING, row.h}, "Shakes your gamepad\nwhen you're hit."); + isDirty |= ui.toggle_draw(game, {keyX, y}, "Rumble", isRumbling); + } + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.hint_set(game, {row.x, y, row.w, row.h}, + "How far the aim stick\nleans before it aims.\nRaise it if it drifts."); + ui.label_draw(game, "Stick Deadzone", {row.x, y}); + if (ui.number_draw(game, {keyX, y, NUMBER_WIDTH, WIDGET_HEIGHT}, stickDeadzone, + {.min = 0, .max = DEADZONE_MAX, .step = DEADZONE_STEP, .suffix = "%"})) + { + input.aimDeadzone = stickDeadzone / PERCENT; + isDirty = true; + } + } } // The colour this screen's player asks for in a match: their pick, the session's random roll, or none. @@ -653,24 +776,41 @@ void Settings::defaults_restore(Game& game) isCollisionShown = fresh.isCollisionShown; isDpsShown = fresh.isDpsShown; isSpeedShown = fresh.isSpeedShown; + isFpsShown = fresh.isFpsShown; + fpsLimit = fresh.fpsLimit; isAimShown = fresh.isAimShown; + isMutedUnfocused = fresh.isMutedUnfocused; + isDamageShown = fresh.isDamageShown; + windowScale = fresh.windowScale; + isChatShown = fresh.isChatShown; + isPingShown = fresh.isPingShown; SDL_strlcpy(serverUrl, fresh.serverUrl, sizeof(serverUrl)); weaponSlots = fresh.weaponSlots; trinketSlots = fresh.trinketSlots; - fullscreen_apply(game); + window_apply(game); game.renderer.vsync_set(isVsync); - game.audio.gain_set(isMuted ? 0 : volume); if (game.sim.match.state == MATCH_PRACTICE) game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); isDirty = true; } -// The lobby server's host, typed into a field (shown on the Multiplayer page), going back to the default when emptied -// and saved once it's no longer being typed in. -void Settings::host_draw(Game& game, Rect field) +// The online settings, two rows from row (here and on the Multiplayer page, each with its own label width): the lobby +// server's host, typed into a field, going back to the default when emptied and saved once it's no longer being typed +// in, then whether other players' chat and everyone's pings show. +void Settings::online_draw(Game& game, Rect row, float labelWidth) { Ui& ui = game.ui; + Rect field = {row.x + labelWidth, row.y, row.w - labelWidth, WIDGET_HEIGHT}; + ui.hint_set(game, row, "The server hosting lobbies.\nEmpty it to reset."); + ui.label_draw(game, "Host", {row.x, row.y}); + float y = row.y + ROW_HEIGHT; + ui.hint_set(game, {field.x, y, TOGGLE_SPACING, WIDGET_HEIGHT}, + "Shows other players'\nmessages. Off, you can\nstill send your own."); + ui.hint_set(game, {field.x + TOGGLE_SPACING, y, TOGGLE_SPACING, WIDGET_HEIGHT}, + "Shows players'\nconnection quality."); + isDirty |= ui.toggle_draw(game, {field.x, y}, "Chat", isChatShown); + isDirty |= ui.toggle_draw(game, {field.x + TOGGLE_SPACING, y}, "Ping", isPingShown); isDirty |= ui.field_draw(game, field, serverUrl, sizeof(serverUrl)); if (serverUrl[0] == '\0' && !ui.is_active(serverUrl)) SDL_strlcpy(serverUrl, SERVER_URL_DEFAULT, sizeof(serverUrl)); @@ -751,6 +891,7 @@ void Settings::profile_draw(Game& game, Rect area, Page& page) float icons = (ICON_PIXELS[ICON_SMALL] + LOADOUT_GAP) * (int)ITEM_KIND_COUNT; if (ui.row_get(page, ROW_HEIGHT + icons, y)) loadouts_draw(game, {list.x, y, separator.w, WIDGET_HEIGHT}, hovered); + stats_draw(game, page, {list.x, y, separator.w, WIDGET_HEIGHT}, hovered); ui.page_end(game, page); // The preview: the fighter in the colour picked and what it wears, its name over it, and under it the colour's name @@ -828,6 +969,7 @@ void Settings::update(Game& game) isFullscreen = EM_ASM_INT({ return !!document.fullscreenElement; }); #endif wasPractice = isPractice; + audio_apply(game); loadouts_update(game); if (isDirty && !game.input.is_mouse_down(MOUSE_LEFT) && !SDL_TextInputActive(game.window)) save(game); @@ -980,3 +1122,57 @@ void Settings::loadouts_draw(Game& game, Rect row, Tooltip& hovered) } } } + +// Profile's record (see Stats) under a separator, a page row each: the totals, then the most and least played weapons +// and trinkets, any never played left out, each hovering to its tooltip at its sandbox level. +void Settings::stats_draw(Game& game, Page& page, Rect row, Tooltip& hovered) +{ + Ui& ui = game.ui; + Stats& stats = game.stats; + Match& match = game.sim.match; + float& y = row.y; + if (ui.row_get(page, ROW_HEIGHT, y)) + ui.separator_draw(game, row, "Stats"); + float value = row.x + LABEL_WIDTH; + char text[LINE_LENGTH]; + if (ui.row_get(page, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Damage Dealt", {row.x, y}); + SDL_snprintf(text, sizeof(text), "%lld", (long long)stats.damage); + ui.label_draw(game, text, {value, y}); + } + for (int play = 0; play < PLAY_KIND_COUNT; play++) + { + if (!ui.row_get(page, ROW_HEIGHT, y)) + continue; + ui.label_draw(game, PLAY_NAMES[play], {row.x, y}); + SDL_snprintf(text, sizeof(text), "Kills: %d", stats.kills[play]); + ui.label_draw(game, text, {value, y}); + SDL_snprintf(text, sizeof(text), "Wins: %d", stats.wins[play]); + ui.label_draw(game, text, {value + STATS_COLUMN_WIDTH, y}); + } + + Fighter& sandbox = match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox; + float size = ICON_PIXELS[ICON_SMALL]; + for (ItemKind kind : ITEM_KINDS) + { + for (int order = 0; order < RANK_ORDER_COUNT; order++) + { + if (!ui.row_get(page, ROW_HEIGHT, y)) + continue; + int items[STATS_RANKED]; + int count = stats.ranked_get(kind, (RankOrder)order, items); + ui.label_draw(game, RANK_LABELS[kind][order], {row.x, y}); + if (count == 0) + ui.label_draw(game, "None yet", {value, y}); + for (int i = 0; i < count; i++) + { + Vec2 centre = {value + i * (size + LOADOUT_GAP) + size / 2, y + WIDGET_HEIGHT / 2}; + ui.icon_draw(game, kind, items[i], {.color = ui.tint, .position = centre}, ICON_SMALL); + Rect box = {centre.x - size / 2, centre.y - size / 2, size, size}; + if (is_point_in_rect(game.input.mouse, box)) + hovered = ui.tooltip_build(game, kind, items[i], sandbox.item_levels_get(kind)[items[i]]); + } + } + } +} diff --git a/src/settings.h b/src/settings.h index 3175aaa..3c45cce 100644 --- a/src/settings.h +++ b/src/settings.h @@ -28,6 +28,10 @@ struct SandboxSave }; constexpr int LOADOUT_COUNT = 9; +// The frame rate limits the FPS limit button steps through, 0 for none. +constexpr int FPS_LIMITS[] = {60, 144, 0}; +// The window scales the Scale button steps through, 0 for Free (resized by hand). +constexpr int WINDOW_SCALES[] = {1, 2, 3, 4, 0}; // A saved loadout: each kind's items by slot (-1 for empty) with their copies' levels, and how many slots it had // (none for a loadout never saved). @@ -55,21 +59,35 @@ class Settings public: char name[NAME_LENGTH + 1] = "Fighter"; float volume = 1; + // The most frames drawn each second (see FPS_LIMITS), 0 for no limit. Vsync paces them instead while it's on. + int fpsLimit = 0; + // How far the aim stick must lean before it aims, in percent. + int stickDeadzone = 30; + // The window's size in canvases (see WINDOW_SCALES), 0 for Free. + int windowScale = 0; int color = COLOR_UNASSIGNED; int fashion = FASHION_NONE; int trinketSlots = 2; int weaponSlots = 2; bool isAimShown = true; + bool isChatShown = true; bool isCollisionShown = false; + bool isDamageShown = true; bool isDpsShown = false; + bool isFpsShown = false; bool isFullscreen = false; bool isMuted = false; + bool isMutedUnfocused = true; + bool isPingShown = true; + bool isRumbling = true; bool isShaking = true; bool isSpeedShown = false; bool isVsync = true; // The server online play goes through (see protocol.h), and whether the Play tab's rules are unfolded. char serverUrl[SERVER_URL_LENGTH] = {}; bool isRulesOpen = true; + // The first-run hints already learned, a bit per Hint. + int hintsLearned = 0; // The saved loadouts, and which one the loadout picker shows (counting from 1). Loadout loadouts[LOADOUT_COUNT]; int loadoutShown = 1; @@ -78,11 +96,12 @@ public: void load(Game& game); void save(Game& game); void apply(Game& game); - void fullscreen_apply(Game& game); + void window_apply(Game& game); + void audio_apply(Game& game); void fullscreen_toggle(Game& game); void draw(Game& game, Rect area, Page& page); void profile_draw(Game& game, Rect area, Page& page); - void host_draw(Game& game, Rect field); + void online_draw(Game& game, Rect row, float labelWidth); void defaults_restore(Game& game); int color_get(); // Marks the settings for saving (as the menu does), for changes made elsewhere, like chat commands. @@ -93,6 +112,7 @@ public: void restore(Game& game); void update(Game& game); void loadouts_draw(Game& game, Rect row, Tooltip& hovered); + void stats_draw(Game& game, Page& page, Rect row, Tooltip& hovered); private: SandboxSave sandbox; @@ -100,6 +120,7 @@ private: Uint64 savedAt = 0; bool isDirty = false; bool wasPractice = true; + float appliedGain = -1; int loadoutPasses = 0; int loadoutPending = -1; diff --git a/src/sim.cmake b/src/sim.cmake index de84872..5fe4c1c 100644 --- a/src/sim.cmake +++ b/src/sim.cmake @@ -16,3 +16,41 @@ if(MSVC) else() target_compile_options(sim PUBLIC -ffp-contract=off -fno-fast-math) endif() + +# Each weapon's muzzle, where its shots leave it, from the "Muzzle" null of its animation in weapons.anm2 (the art never +# loads into the simulation, so it's read here, again whenever the file changes). It reaches weapon.cpp as WEAPON_MUZZLES, +# a {"name", x, y} entry for each weapon that has one. +set(WEAPONS_ANM2 ${CMAKE_CURRENT_LIST_DIR}/../resources/gfx/weapons.anm2) +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${WEAPONS_ANM2}) +file(READ ${WEAPONS_ANM2} weaponsArt) +set(muzzleNull -1) +string(JSON nullCount ERROR_VARIABLE noNulls LENGTH "${weaponsArt}" nulls) +if(NOT noNulls) + math(EXPR lastNull "${nullCount} - 1") + foreach(index RANGE ${lastNull}) + string(JSON nullName GET "${weaponsArt}" nulls ${index} name) + if(nullName STREQUAL "Muzzle") + set(muzzleNull ${index}) + endif() + endforeach() +endif() +set(muzzles "") +string(JSON animationCount LENGTH "${weaponsArt}" animations) +math(EXPR lastAnimation "${animationCount} - 1") +foreach(index RANGE ${lastAnimation}) + string(JSON animation MEMBER "${weaponsArt}" animations ${index}) + string(JSON trackCount ERROR_VARIABLE noTracks LENGTH "${weaponsArt}" animations ${animation} nulls) + if(noTracks OR trackCount EQUAL 0) + continue() + endif() + math(EXPR lastTrack "${trackCount} - 1") + foreach(track RANGE ${lastTrack}) + string(JSON null GET "${weaponsArt}" animations ${animation} nulls ${track} null) + if(null EQUAL muzzleNull) + string(JSON x GET "${weaponsArt}" animations ${animation} nulls ${track} frames 0 pos 0) + string(JSON y GET "${weaponsArt}" animations ${animation} nulls ${track} frames 0 pos 1) + string(APPEND muzzles "{\"${animation}\",${x},${y}},") + endif() + endforeach() +endforeach() +target_compile_definitions(sim PRIVATE "WEAPON_MUZZLES=${muzzles}") diff --git a/src/sim.h b/src/sim.h index 4e450a5..0ea1f84 100644 --- a/src/sim.h +++ b/src/sim.h @@ -21,7 +21,7 @@ public: {} virtual void shake_add(float amount) {} - virtual void flash_spawn(Vec2 position, float angle, float size, int owner) + virtual void flash_spawn(Attack& attack, float size) {} virtual void fighter_afterimage_spawn(Fighter& fighter) {} diff --git a/src/stats.cpp b/src/stats.cpp new file mode 100644 index 0000000..67f96f2 --- /dev/null +++ b/src/stats.cpp @@ -0,0 +1,123 @@ +#include "stats.h" +#include "game.h" +#include +#include + +constexpr const char* KEY_STATS_DAMAGE = "stats_damage"; +constexpr const char* KEY_STATS_KILLS = "stats_kills"; +constexpr const char* KEY_STATS_WINS = "stats_wins"; +constexpr const char* KEY_STATS_TIMES[ITEM_KIND_COUNT] = {"stats_weapon_times", "stats_trinket_times"}; + +// Takes in what this screen's fighter has newly dealt, killed and won, and adds a tick to the weapon it holds and each +// trinket it wears while it plays out of the menu. +void Stats::update(Game& game) +{ + if (game.session.is_watching(game)) + return; + Fighter& fighter = game.local_get(); + Match& match = game.sim.match; + PlayKind play = match.isOnline ? PLAY_MULTI : PLAY_SINGLE; + bool isFresh = fighter.damageDealt == 0 && fighter.kills == 0 && fighter.wins == 0; + if (!isFresh) + { + damage += fighter.damageDealt - seenDamage; + kills[play] += fighter.kills - seenKills; + wins[play] += fighter.wins - seenWins; + } + if (fighter.wins != seenWins) + game.settings.changed(); + seenDamage = fighter.damageDealt; + seenKills = fighter.kills; + seenWins = fighter.wins; + + bool isPlaying = match.state == MATCH_PRACTICE || match.state == MATCH_FIGHT; + if (!isPlaying || game.menu.isOpen || !fighter.isAlive) + return; + int weapon = fighter.weaponLoadout[fighter.slot]; + if (weapon >= 0) + weaponTimes[weapon] += TICK; + int* worn = fighter.trinketLoadout; + for (int i = 0; i < fighter.trinketSlots; i++) + if (worn[i] >= 0 && std::find(worn, worn + i, worn[i]) == worn + i) + trinketTimes[worn[i]] += TICK; +} + +// Reads one saved line, if it's a stat's: totals as "single,multi", times as "name:seconds" by item. +void Stats::read(const char* key, char* value) +{ + if (SDL_strcmp(key, KEY_STATS_DAMAGE) == 0) + damage = SDL_strtod(value, nullptr); + const char* comma = SDL_strchr(value, ','); + int* pairs[] = {SDL_strcmp(key, KEY_STATS_KILLS) == 0 ? kills : nullptr, + SDL_strcmp(key, KEY_STATS_WINS) == 0 ? wins : nullptr}; + for (int* pair : pairs) + { + if (!pair || !comma) + continue; + pair[PLAY_SINGLE] = SDL_atoi(value); + pair[PLAY_MULTI] = SDL_atoi(comma + 1); + } + + for (ItemKind kind : ITEM_KINDS) + { + if (SDL_strcmp(key, KEY_STATS_TIMES[kind]) != 0) + continue; + char* rest = nullptr; + for (char* entry = SDL_strtok_r(value, ",", &rest); entry; entry = SDL_strtok_r(nullptr, ",", &rest)) + { + char* colon = SDL_strchr(entry, ':'); + if (!colon) + continue; + *colon++ = '\0'; + int item = item_find(kind, entry); + if (item >= 0) + times_get(kind)[item] = SDL_strtod(colon, nullptr); + } + } +} + +// Writes the stats as saved lines, returning their length. Items never played are left out. +int Stats::write(char* text, int size) +{ + int length = + SDL_snprintf(text, size, "%s=%lld\n%s=%d,%d\n%s=%d,%d\n", KEY_STATS_DAMAGE, (long long)damage, KEY_STATS_KILLS, + kills[PLAY_SINGLE], kills[PLAY_MULTI], KEY_STATS_WINS, wins[PLAY_SINGLE], wins[PLAY_MULTI]); + for (ItemKind kind : ITEM_KINDS) + { + double* times = times_get(kind); + length += SDL_snprintf(text + length, size - length, "%s=", KEY_STATS_TIMES[kind]); + const char* gap = ""; + for (int item = 0; item < item_count_get(kind); item++) + { + if (times[item] <= 0) + continue; + length += + SDL_snprintf(text + length, size - length, "%s%s:%.1f", gap, item_name_get(kind, item), times[item]); + gap = ","; + } + length += SDL_snprintf(text + length, size - length, "\n"); + } + return length; +} + +// Fills items with up to STATS_RANKED of a kind's most or least played, leaving out any never played, and returns how +// many. +int Stats::ranked_get(ItemKind kind, RankOrder order, int* items) +{ + double* times = times_get(kind); + int played[std::max((int)WEAPON_COUNT, (int)TRINKET_COUNT)]; + int count = 0; + for (int item = 0; item < item_count_get(kind); item++) + if (times[item] > 0) + played[count++] = item; + std::sort(played, played + count, + [&](int a, int b) { return order == RANK_TOP ? times[a] > times[b] : times[a] < times[b]; }); + count = std::min(count, STATS_RANKED); + std::copy(played, played + count, items); + return count; +} + +double* Stats::times_get(ItemKind kind) +{ + return kind == ITEM_TRINKET ? trinketTimes : weaponTimes; +} diff --git a/src/stats.h b/src/stats.h new file mode 100644 index 0000000..dcaa9f9 --- /dev/null +++ b/src/stats.h @@ -0,0 +1,51 @@ +#pragma once + +#include "trinket.h" +#include "weapon.h" + +struct Game; + +// Where a stat was earned: offline (against bots, or with others on this screen) or online. +enum PlayKind +{ + PLAY_SINGLE, + PLAY_MULTI, + PLAY_KIND_COUNT +}; + +enum RankOrder +{ + RANK_TOP, + RANK_BOTTOM, + RANK_ORDER_COUNT +}; + +// How many items each of Profile's most and least played rows shows. +constexpr int STATS_RANKED = 3; + +// The player's lifetime record, shown in Profile: damage dealt anywhere, deathmatch kills and matches won by where they +// were played, and how long each weapon was held and each trinket worn while playing outside the menu. Damage, kills +// and wins come from the running totals this screen's fighter keeps in the game, taken as they change, so a rewind +// online takes back what it undoes. A fighter whose totals are all zero is a fresh one, and counting starts over from +// it. Saved with the settings. +class Stats +{ +public: + double damage = 0; + int kills[PLAY_KIND_COUNT] = {}; + int wins[PLAY_KIND_COUNT] = {}; + + void update(Game& game); + void read(const char* key, char* value); + int write(char* text, int size); + int ranked_get(ItemKind kind, RankOrder order, int* items); + +private: + double trinketTimes[TRINKET_COUNT] = {}; + double weaponTimes[WEAPON_COUNT] = {}; + float seenDamage = 0; + int seenKills = 0; + int seenWins = 0; + + double* times_get(ItemKind kind); +}; diff --git a/src/trinket.h b/src/trinket.h index 3eda460..4ff6058 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -1,5 +1,6 @@ #pragma once +#include #include "weapon.h" struct Game; @@ -299,3 +300,12 @@ inline const char* item_name_get(ItemKind kind, int item) { return kind == ITEM_TRINKET ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name; } + +// An item's number from its name, or -1. Items are saved by name so reordering them never scrambles a save. +inline int item_find(ItemKind kind, const char* name) +{ + for (int i = 0; i < item_count_get(kind); i++) + if (std::strcmp(name, item_name_get(kind, i)) == 0) + return i; + return -1; +} diff --git a/src/ui.cpp b/src/ui.cpp index 997273b..e86cd4f 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -54,6 +54,8 @@ constexpr float CHARGE_BAR_HEIGHT = 8; constexpr float CHARGE_BAR_WIDTH = 20; constexpr float DPS_SCALE = 2; constexpr float FIGHT_BANNER_TIME = 0.6f; +// How many of the countdown's last seconds beep (and, in a timed round, show big). +constexpr int COUNTDOWN_BEEPS = 5; constexpr float HEALTH_BAR_HEIGHT = 7; constexpr float HEALTH_BAR_WIDTH = 28; constexpr float ROUND_SCALE = 3; @@ -114,6 +116,14 @@ constexpr float FIELD_INSET = 4; constexpr float SLIDER_INSET = 3; constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2; constexpr float HINT_FADE = 0.5f; +constexpr const char* HINT_SPACING = " "; +// How wide a row of first-run hints may run, clear of the hotbars in the corners. +constexpr float HINT_ROW_WIDTH = 360; +// Each first-run hint's control (Move takes Move Right too) and what it does. +constexpr Control HINT_CONTROLS[HINT_COUNT] = {CONTROL_LEFT, CONTROL_JUMP, CONTROL_DOWN, CONTROL_ATTACK, + CONTROL_CHARGE, CONTROL_MENU, CONTROL_READY, CONTROL_CHAT}; +constexpr const char* HINT_LABELS[HINT_COUNT] = {"Move", "Jump", "Crouch / Drop", "Attack", + "Charged Attack", "Menu", "Reset", "Chat"}; constexpr float TOOLTIP_OFFSET = 12; constexpr float TOOLTIP_PADDING = 6; constexpr float TABLE_GAP = 8; @@ -132,44 +142,107 @@ constexpr Vec2 LABEL_OFFSETS[ICON_TINY] = {{4, 2}, {3, 2}}; constexpr Vec2 ICON_LABEL_OFFSET = {LABEL_OFFSETS[ICON_FULL].x - (SLOT_SIZE - ICON_SIZE) / 2, LABEL_OFFSETS[ICON_FULL].y - (SLOT_SIZE - ICON_SIZE) / 2}; +// Tips shown under the ready button, one at random: movement and knockback, cooldowns and Charge, projectiles, each +// Weapon and Trinket in turn, then the menus. constexpr const char* TIPS[] = { - "Big hits launch fighters, and slamming into walls while launched hurts.", - "Swing the Hammer into a wall to rebound off it, even mid-launch.", - "Ranged shots lose damage the farther they fly. Get close.", - "Explosions hit hardest at their centre.", - "Your own explosions push you but never hurt you. Rocket jump!", - "Arrows stuck in walls are solid to you. Stand on them.", - "Charged boomerangs are platforms only you can ride.", - "Standing in your own flames brakes you, even mid-launch.", - "Shots carry your momentum. Fire while charging forward for extra damage.", - "Every hit is stronger the faster you're moving.", - "Let go of a charge early for a normal attack that hits harder.", - "Mines need a moment to arm before they'll go off.", - "The Whip's charged blast only lands at the very tip.", - "A charged grapple hit drags you straight to your target.", - "Platforms block shots. Use them as cover.", - "With Iron Weights, hold Down to slam the ground. Faster falls hit harder.", - "Ricochet shots add damage with every bounce.", - "Your charge carries over when you swap weapons.", - "Charge recalls your boomerangs once they're all out.", - "Attack or Charge reels a thrown flail ball back in.", - "Uzi and Blaster hits barely push. Melee and explosions send fighters flying.", - "Rapid small hits can't juggle you. Knockback tops up, it doesn't stack.", - "Crack the Whip against a wall to be pulled toward it.", - "Recoil is movement. Fire away from where you want to go.", - "Splitter copies fan wider with each level.", - "Dual Wield fires your previous weapon from your off hand too.", - "Tesla Coil chains lightning between your weapons out in the world.", - "Each round levels up every item, and health rises with it.", - "With the Feather, hold Up in the air to float.", - "The Lance's charged poke lunges you forward.", - "In a race, dithered pits send you back to the last solid ground you stood on.", - "Race blocks grow back a few seconds after they break. Don't dawdle.", - "Hits in a race don't hurt, but they still knock. Push rivals into pits.", - "Shotgun recoil can double as a jump.", + "Getting knocked into walls hurts! Do your best to reduce your speed when attacked!", + "Don't get cornered! Knockback damage can add up fast!", + "Crouch to reduce knockback!", + "Crawl around by crouching and moving!", + "Roll around on sloped surfaces with Down!", + "Platforms block shots. Use them as cover!", + + "Weapons don't share cooldowns! Switch Weapons to maximize damage!", + "When a Weapon enters cooldown, it will fade and return to normal when it's done!", + "Your cursor's color and vibration can show you your Weapon's cooldown or Charge!", + "Let go of a Charge early for a Basic Attack that hits harder!", + "Your Charge carries over when you swap Weapons!", + "Higher damaging attacks will glow and vibrate!", + "Going faster makes you glow and vibrate, showing that your attacks deal more damage!", + + "Almost every attack counts as a projectile! See how you can change their traits with Trinkets!", + "Projectiles inherit your velocity. Go fast for big damage!", + "Projectiles lose damage the longer they're out. Get close!", + "Projectiles will fade with time; this fade shows their damage potential!", + "Increasing a projectile's lifetime or speed can both increase its range!", + "Explosions hit hardest at their center!", + "Your explosions don't hurt, and only push you. Try rocket jumping!", + "Recoil is movement. Fire away from where you want to go!", + + "Mix and match Weapons and Trinkets for nigh-infinite amounts of combos!", + "The Fist's Charged Attack doesn't always send you straight up! Change your aim!", + "Slam a Hammer into surfaces to fly far!", + "The Lance's Charged Attack lunges you forward!", + "Crack the Whip against a wall to be pulled toward it!", + "The Whip's Charged Attack only blasts at the very tip, but does big damage!", + "Charge a Flail to throw its ball, and Charge again to reel it back!", + "You can have the Flail's ball extended and another Weapon equipped for a dangerous combo!", + "Charged Boomerangs are platforms only you can ride!", + "Charge recalls your Boomerangs!", + "A Grapple's Charged Attack yanks you to your target!", + "Swing around on or release a latched Grapple for big speed!", + "You can have a Grapple latched and another Weapon equipped for an acrobatic fight!", + "A Bow's arrows stuck in walls are semi-solid platforms!", + "The Bow's arrows fly faster and further with Charge!", + "Fire a wide volley with the Uzi's Charged Attack!", + "Use a Shotgun's recoil for great movement!", + "The Bolter's projectiles can cling onto surfaces, perfect for area denial!", + "Cook a Grenade Launcher's grenade via Charging to explode it sooner!", + "Fully Charging a Grenade Launcher will cause an explosion in your face!", + "Hit the Grenade Launcher's projectile and explosion at the same time for big damage! Double Donk!", + "Mines need a moment to arm before they'll go off!", + "The Flamethrower's Charged Attack detonates mid-air! Space an explosion!", + "The Vacuum's Charged Attack deals big knockback! Send foes flying!", + "The Vacuum's Charged Attack turns enemy shots into your own!", + "Hold your own projectiles in the Vacuum's suction to keep them fresh!", + "The Vacuum's suction and Charged Attack can move projectiles around. Manipulate the battlefield!", + "Use the Grimoire's wisps as a close-range attack or to pepper from afar!", + "Use the Grimoire's teleporting Charged Attack to escape danger!", + + "Diazepam reduces Weapon spread, perfect for concentrated attacks!", + "Splitter copies fan wider with each level!", + "The off-hand Weapon from Dual Wield is the one you last switched from!", + "Ricochet makes projectiles deal more damage the more they've bounced!", + "Ricochet bounces reset a projectile's lifetime!", + "Tesla Coil arcs electricity between your projectiles. Make traps or wide danger zones!", + "The kickback the Recoil Coil gives is perfect for movement!", + "Stay in place with a Shock Absorber!", + "Embiggener makes you a bigger target, but gives a solid damage buff!", + "Make yourself a living hazard with a Pebble Helmet!", + "With a Speedometer, moving fast and attacking deals bigger damage!", + "A pair of Wings will make you dash to where you're aiming when you jump mid-air!", + "With the Feather, hold Up in the air to float!", + "With Iron Weights, faster falls produce bigger blasts!", + "Cling, slide, and kick off walls with a Climb Claw!", + + "Create a Lobby and share its link or code with friends for multiplayer fun!", + "When in a Lobby, queue up a game in the menu!", + "Queuing takes place in the Sandbox! Holler fighters over to your queued area and Ready up!", + "Change a game's rules underneath the Rules option!", + "If a game has a time limit, the winner will be decided based on who has the most health!", + "Is your name \"Fighter\"? Change it in Profile!", + "Change your fighter and HUD color in Profile!", + "Feeling naked and unsexy? Wear some Fashion within your Profile!", + "Set and load custom gear Loadouts in your Profile!", + "Save a gear Loadout and use Shift + a number to equip it!", + "Turn on DPS in Settings to see the damage you deal each second!", + "Turn on Speed in Settings to see how fast you're going!", + "Turn on FPS in Settings to see how smoothly the game runs!", + "Is previewing your aim too intrusive? Toggle Aim Preview off in Settings!", + "Want to see how things actually collide? Toggle Display Collision in Settings!", + "Game too loud? Turn down the Volume or Mute it in Settings!", + "Don't like the Controls? Tweak them to your liking in Settings!", + "Want to experiment with more Weapon or Trinket slots in the Sandbox? Change them in Settings!", + "Want to fight in the Sandbox with others? Check PvP in Settings or use the /pvp command!", + "The Dummy in the Sandbox doesn't feel pain! Beat it as much as you'd like, it enjoys it!", + "Throw tomatoes to mock a game's winner in between rounds by clicking anywhere on screen!", + "Sling slurs and trashtalk all you wish with a Chat message!", + "A number of useful / commands are present in Chat! Type /help to see!", + "Want to see the sexy person who made this game? Check About in the menu!", }; static CountdownInfo countdown_info_get(Game& game); +static float rival_ping_get(Game& game, int fighter); // How opaque a HUD element (other than a hotbar slot) is, fading as the mouse comes over it. static float hover_alpha_get(float hover) @@ -287,7 +360,7 @@ void Ui::bars_draw(Game& game) const char* name = name_get(game, i); Vec2 size = game.text.size_get(name); bottom -= size.y; - float ping = game.session.isActive ? game.session.ping_get(game, match.owners[i]) : PING_NONE; + float ping = rival_ping_get(game, i); float width = size.x + (ping == PING_NONE ? 0 : NAME_PING_GAP + ping_width_get(game, ping)); float left = std::round(centerX - width / 2); game.text.draw(renderer, name, {.color = color, .position = {left, bottom}}); @@ -314,7 +387,7 @@ void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float s color); } -// In practice, a hint to open the menu (with its key), fading once it first opens. In a match, the round wins and +// In practice, the first-run hints (see hints_draw). In a match, the round wins and // clock along the top, and a banner for the countdown (with who's ready, a Ready button and a tip), the start of the // fight, the end of a round and the end of the match. void Ui::match_draw(Game& game) @@ -325,12 +398,7 @@ void Ui::match_draw(Game& game) Input& input = game.input; if (match.state == MATCH_PRACTICE) { - char key[TOOLTIP_LENGTH]; - char reset[TOOLTIP_LENGTH]; - input.control_name_get(CONTROL_MENU, key, sizeof(key)); - input.control_name_get(CONTROL_READY, reset, sizeof(reset)); - SDL_snprintf(text, sizeof(text), "%s: Menu %s: Reset", key, reset); - text_center_draw(game, text, CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT, 1, hintAlpha); + hints_draw(game); return; } @@ -340,8 +408,8 @@ void Ui::match_draw(Game& game) else { // The score: each fighter's wins in its own colour, the dashes between them white. It's centred along the top, - // unless a long weapon hotbar reaches it, when it moves right to clear the hotbar (the clock under it moves - // with it). + // unless a long weapon hotbar reaches it in the HUD, when it moves right to clear the hotbar (the clock under + // it moves with it). It stays put while the menu moves the hotbar. Renderer& renderer = game.renderer; int length = 0; for (int i = 0; i < game.sim.fighterCount; i++) @@ -349,7 +417,7 @@ void Ui::match_draw(Game& game) Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; int weapons = game.local_get().weaponSlots; - Rect last = game.menu.slot_rect(ITEM_WEAPON, weapons - 1, weapons); + Rect last = game.menu.slot_hud_rect(ITEM_WEAPON, weapons - 1, weapons); float hotbarRight = last.x + last.w; float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); float center = x + scoreWidth / 2; @@ -379,6 +447,7 @@ void Ui::match_draw(Game& game) HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}}); } + countdown_beep(game); switch (match.state) { case MATCH_COUNTDOWN: @@ -439,6 +508,12 @@ void Ui::match_draw(Game& game) } if (match.timer > -FIGHT_BANNER_TIME) text_center_draw(game, isRace ? "Go!" : "Fight!", BANNER_Y, BANNER_SCALE); + // A timed round's last seconds count down big. + if (match.time_limit_get() > 0 && countdownBeeped > 0) + { + SDL_snprintf(text, sizeof(text), "%d", countdownBeeped); + text_center_draw(game, text, BANNER_Y, BANNER_SCALE); + } break; case MATCH_ROUND_OVER: if (match.winner >= 0) @@ -828,6 +903,108 @@ static CountdownInfo countdown_info_get(Game& game) return info; } +// Marks a first-run hint learned once this screen's fighter has done what it says (a full charge for the charged +// attack), keeping it in the settings across launches, and fades learned ones out. +void Ui::hints_update(Game& game) +{ + Fighter& local = game.local_get(); + int& learned = game.settings.hintsLearned; + bool isDone[HINT_COUNT] = { + local.is_down(CONTROL_LEFT) || local.is_down(CONTROL_RIGHT), + local.is_down(CONTROL_JUMP), + local.is_down(CONTROL_DOWN), + local.is_down(CONTROL_ATTACK), + local.charge >= 1, + game.menu.isOpen, + local.is_down(CONTROL_READY), + game.chat.isOpen, + }; + for (int i = 0; i < HINT_COUNT; i++) + { + int bit = 1 << i; + if (isDone[i] && !(learned & bit) && !game.session.is_watching(game)) + { + learned |= bit; + game.settings.changed(); + } + hintAlphas[i] = learned & bit ? std::max(0.0f, hintAlphas[i] - TICK / HINT_FADE) : 1; + } +} + +// The first-run hints still showing, each with its key, centred in rows along the bottom of the sandbox, a row going up +// when they'd run too wide for one. +void Ui::hints_draw(Game& game) +{ + Input& input = game.input; + char texts[HINT_COUNT][TOOLTIP_LENGTH]; + float widths[HINT_COUNT]; + int shown[HINT_COUNT]; + int count = 0; + for (int i = 0; i < HINT_COUNT; i++) + { + if (hintAlphas[i] <= 0 || (i == HINT_CHAT && !game.session.isActive)) + continue; + char key[TOOLTIP_LENGTH]; + char other[TOOLTIP_LENGTH]; + input.control_name_get(HINT_CONTROLS[i], key, sizeof(key)); + input.control_name_get(CONTROL_RIGHT, other, sizeof(other)); + if (i == HINT_MOVE) + SDL_snprintf(texts[count], sizeof(texts[count]), "%s/%s: %s", key, other, HINT_LABELS[i]); + else + SDL_snprintf(texts[count], sizeof(texts[count]), i == HINT_CHARGE ? "Hold %s: %s" : "%s: %s", key, + HINT_LABELS[i]); + widths[count] = game.text.size_get(texts[count]).x; + shown[count++] = i; + } + + // Rows fill greedily, then stack up from the bottom. + float gap = game.text.size_get(HINT_SPACING).x; + float limit = HINT_ROW_WIDTH; + int rowStarts[HINT_COUNT + 1]; + int rows = 0; + float width = limit; + for (int i = 0; i < count; i++) + { + if (width + gap + widths[i] > limit) + { + rowStarts[rows++] = i; + width = widths[i]; + continue; + } + width += gap + widths[i]; + } + rowStarts[rows] = count; + for (int row = 0; row < rows; row++) + { + float total = -gap; + for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) + total += widths[i] + gap; + float x = std::round((CANVAS_WIDTH - total) / 2); + float y = CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT * (rows - row); + for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) + { + game.text.draw(game.renderer, texts[i], + {.color = color_fade(tint, hintAlphas[shown[i]]), .position = {x, y}}); + x += widths[i] + gap; + } + } +} + +// The last COUNTDOWN_BEEPS seconds of the ready countdown, or of a timed round, beep as each number ticks over. +void Ui::countdown_beep(Game& game) +{ + Match& match = game.sim.match; + float left = 0; + if (match.state == MATCH_COUNTDOWN) + left = match.timer; + if (match.state == MATCH_FIGHT && match.time_limit_get() > 0) + left = match.time_limit_get() - match.clock; + int second = left > 0 && left <= COUNTDOWN_BEEPS ? (int)std::ceil(left) : 0; + if (second > 0 && second != countdownBeeped) + game.audio.play(SOUND_COUNTDOWN); + countdownBeeped = second; +} + // The countdown while the menu covers the middle (as when choosing items), drawn over the menu's dimming: the round and // seconds left in the margin left of the menu, who's ready in the margin right of it (with the key to ready up until // this screen has), and the tip under the hotbars, below the gamepad hints if they're showing. The round's name drops @@ -893,8 +1070,8 @@ void Ui::text_center_draw(Game& game, const char* text, float y, float scale, Co {.color = color, .position = {std::round((CANVAS_WIDTH - width) / 2), std::round(y)}, .scale = {scale, scale}}); } -// Top right: during a match, the local fighter's health as a number beside a bar, then damage per second and the -// local fighter's speed in units per second. +// Top right: during a match, the local fighter's health as a number beside a bar, then, smaller, frames drawn per +// second (counted over each second), damage per second and the local fighter's speed in units per second. void Ui::status_draw(Game& game) { char text[TOOLTIP_LENGTH]; @@ -918,13 +1095,26 @@ void Ui::status_draw(Game& game) y += rowHeight; } - // The DPS and speed meters each show only if they're on in the settings. + // The meters each show only if they're on in the settings. auto meter_draw = [&](const char* label) { - float x = std::round(right - game.text.size_get(label, FONT_SMALL, DPS_SCALE).x); + float x = std::round(right - game.text.size_get(label).x); left = std::min(left, x); - game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}, .scale = {DPS_SCALE, DPS_SCALE}}); - y += rowHeight; + game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}}); + y += game.text.line_height_get(); }; + Uint64 now = SDL_GetTicks(); + fpsFrames++; + if (now - fpsSince >= SDL_MS_PER_SECOND) + { + fps = fpsFrames * (float)SDL_MS_PER_SECOND / (now - fpsSince); + fpsSince = now; + fpsFrames = 0; + } + if (game.settings.isFpsShown) + { + SDL_snprintf(text, sizeof(text), "%d FPS", (int)std::round(fps)); + meter_draw(text); + } if (game.settings.isDpsShown) { SDL_snprintf(text, sizeof(text), "%.1f DPS", game.effects.dps_get()); @@ -2055,9 +2245,7 @@ static void bars_arrange(CooldownBar* bars) // Keeps the HUD's timers and the cooldown bars up to date. void Ui::update(Game& game) { - isMenuSeen |= game.menu.isOpen; - if (isMenuSeen) - hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE); + hints_update(game); spectate_update(game); // The HUD's elements change while the mouse is over them, and change back after. @@ -2364,7 +2552,8 @@ void Ui::spectate_draw(Game& game) // A fighter's connection for its window, online (PING_NONE otherwise). static float rival_ping_get(Game& game, int fighter) { - return game.session.isActive ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE; + bool isShown = game.session.isActive && game.settings.isPingShown; + return isShown ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE; } // Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more diff --git a/src/ui.h b/src/ui.h index e61c762..bdb7d9b 100644 --- a/src/ui.h +++ b/src/ui.h @@ -198,6 +198,24 @@ struct Slot // How wide a fighter preview's column is (see Ui::fighter_preview_draw). constexpr float FIGHTER_PREVIEW_WIDTH = 120; +// The first-run hints along the bottom of the sandbox, each gone for good once its control has been used. Chat's shows +// only in a lobby. +enum Hint +{ + HINT_MOVE, + HINT_JUMP, + HINT_DOWN, + HINT_ATTACK, + HINT_CHARGE, + HINT_MENU, + HINT_RESET, + HINT_CHAT, + HINT_COUNT +}; + +// Each hint's name in settings.txt. +constexpr const char* HINT_KEYS[HINT_COUNT] = {"move", "jump", "down", "attack", "charge", "menu", "reset", "chat"}; + class Ui { public: @@ -292,7 +310,7 @@ private: bool isFocusing = false; bool isScrolled = false; char numberText[TOOLTIP_LENGTH] = {}; - float hintAlpha = 1; + float hintAlphas[HINT_COUNT] = {}; // The switching controls as they were last tick, to catch presses while spectating. bool spectateHeld[3] = {}; // How far the mouse being over each HUD element has changed it, from 0 to 1: hotbar slots (weapons, then trinkets) @@ -301,11 +319,16 @@ private: float slotHovers[ITEM_KIND_COUNT][SLOT_MAX] = {}; float rivalHovers[MAX_FIGHTERS] = {}; float statusHover = 0; + // Frames drawn since fpsSince (in milliseconds), and the rate they last came to. + float fps = 0; + Uint64 fpsSince = 0; + int fpsFrames = 0; float loadoutTop = 0; float widgetAlpha = 1; int tip = 0; + // The second of the countdown last beeped for (see countdown_beep). + int countdownBeeped = 0; bool isActiveSeen = false; - bool isMenuSeen = false; bool isTipChosen = false; bool isWidgetEnabled = true; bool isWidgetLive = false; @@ -317,6 +340,9 @@ private: void race_draw(Game& game); float results_draw(Game& game, float y); void countdown_aside_draw(Game& game); + void countdown_beep(Game& game); + void hints_update(Game& game); + void hints_draw(Game& game); void text_center_draw(Game& game, const char* text, float y, float scale, float alpha = 1); void text_center_draw(Game& game, const char* text, float y, float scale, Color color); void hotbar_draw(Game& game); diff --git a/src/weapon.cpp b/src/weapon.cpp index cb4f58a..dca43e0 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -1,7 +1,9 @@ #include "weapon.h" #include "sim.h" #include +#include #include +#include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; @@ -67,8 +69,21 @@ constexpr float UPPERCUT_REACH = 16; constexpr float VOLLEY_GAP = 6; constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; -constexpr float WHIP_COIL_GAP = 3; -constexpr int WHIP_COIL_STEPS = 48; +// A plain lash's motion, as shares of its lifetime: the handle draws back until WHIP_WINDUP, drives forward until +// WHIP_THROW and stops, the loop that sends off reaches the tip as it cracks at PATH_OUT, and from WHIP_SETTLE the +// hand draws it back in. Its heading is in radians off the aim, toward the side it rolls over. +constexpr int WHIP_LINKS = 16; +constexpr float WHIP_WINDUP = 0.12f; +constexpr float WHIP_THROW = 0.24f; +constexpr float WHIP_SETTLE = 0.6f; +constexpr float WHIP_RAISED = 1.6f; +constexpr float WHIP_REAR = 2.5f; +constexpr float WHIP_DROOP = -0.9f; +constexpr float WHIP_TAPER = 0.7f; +constexpr float WHIP_KICK = 2.6f; +constexpr float WHIP_WOBBLES = 1; +constexpr float WHIP_DAMPING = 0.02f; +constexpr float WHIP_SAG = 0.6f; constexpr float WHIP_PULL = 220; constexpr float WOBBLE_IMPULSE = 0.15f; // Vacuum: how fast its pull speeds fighters and shots up toward it, how much of an airblast's knock is left at the edge @@ -364,37 +379,101 @@ float strength_get(Attack& attack, Vec2 point) return attack.bonus * multiplier_get(attack, point); } +// A weapon's muzzle as weapons.anm2 places it: from the hand along the aim (x) and to its side (y, flipped facing +// left). +struct Muzzle +{ + const char* weapon; + float x; + float y; +}; + +// Each weapon's muzzle by its place in WEAPON_DEFS, from the Muzzle nulls in weapons.anm2 (read in at build time, see +// sim.cmake), or MUZZLE_DISTANCE straight ahead for a weapon without one. +static constexpr std::array MUZZLES = [] { + constexpr Muzzle NULLS[] = {WEAPON_MUZZLES{nullptr, 0, 0}}; + std::array muzzles; + for (int weapon = 0; weapon < WEAPON_COUNT; weapon++) + { + muzzles[weapon] = {MUZZLE_DISTANCE, 0}; + for (const Muzzle& null : NULLS) + if (null.weapon && std::string_view(null.weapon) == WEAPON_DEFS[weapon].name) + muzzles[weapon] = {null.x, null.y}; + } + return muzzles; +}(); + +// Where an attack's shots leave its weapon, held at its origin and pointing along its angle: the weapon's muzzle (see +// MUZZLES), turned to the angle and mirrored facing left. +Vec2 muzzle_get(Attack& attack) +{ + Vec2 muzzle = MUZZLES[attack.weapon]; + float side = attack.isLeft ? -1 : 1; + return attack.origin + vec2_polar(attack.angle, muzzle.x) + vec2_polar(attack.angle + PI / 2, muzzle.y * side); +} + // How much of an attack's black fill its colour takes over, from none at plain damage to all at FILL_STRENGTH. float fill_get(float strength) { return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); } -// A plain lash rolling out: straight from the handle to its front, then the rest of the whip still wound there in an -// even spiral (its rings WHIP_COIL_GAP apart), curling up and in toward its end, so the coil unwinds as it unrolls. It -// lies straight as it cracks, and coming back it winds up again on the other side, below. +// Where a plain lash's handle points at a share of its lifetime, off the aim (see WHIP_WINDUP): eased back slowly, +// driven forward sharply over the top, held along the aim through the crack, then drooping. +static float lash_heading_get(float time) +{ + time = std::max(0.0f, time); + if (time < WHIP_WINDUP) + { + float ease = 1 - time / WHIP_WINDUP; + return math_lerp(WHIP_RAISED, WHIP_REAR, 1 - ease * ease); + } + if (time < WHIP_THROW) + { + float drive = (time - WHIP_WINDUP) / (WHIP_THROW - WHIP_WINDUP); + return math_lerp(WHIP_REAR, 0, drive * drive * (3 - 2 * drive)); + } + if (time < PATH_OUT) + return 0; + return WHIP_DROOP * std::min(1.0f, (time - PATH_OUT) / (WHIP_SETTLE - PATH_OUT)); +} + +// The way a heading off a plain lash's aim points, turning toward the side it rolls over. +static Vec2 lash_direction_get(Attack& attack, float heading) +{ + Vec2 forward = vec2_polar(attack.aimAngle, 1); + Vec2 up = attack.isLeft ? Vec2{-forward.y, forward.x} : Vec2{forward.y, -forward.x}; + return forward * math_cos(heading) + up * math_sin(heading); +} + +// A plain lash as a chain of links from the end of its handle. Each link points where the handle pointed a moment +// before, longer toward the tip but less so for the lighter links, so the handle's stop rolls a loop down it that +// straightens the tip as it cracks. After, the tip snaps back over and wobbles out ever slower while the slack whip +// sags, and the hand draws it in. std::vector lash_get(Attack& attack) { - Vec2 direction = vec2_polar(attack.angle, 1); - Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x}; - up = attack.progress_get() > PATH_OUT ? up * -1 : up; - float coil = attack.length - vec2_length(attack.tip - attack.position); - std::vector points = {attack.position, attack.tip}; - if (coil < 1) - return points; + float time = attack.progress_get(); + float recoil = std::max(0.0f, (time - PATH_OUT) / (1 - PATH_OUT)); + float slack = time < PATH_OUT ? std::max(0.0f, 1 - time / WHIP_THROW) : std::min(1.0f, 2 * recoil); + float kick = time < PATH_OUT + ? 0 + : WHIP_KICK * math_sin(2 * PI * WHIP_WOBBLES * std::sqrt(recoil)) * math_pow(WHIP_DAMPING, recoil); + float drawn = time < WHIP_SETTLE ? 1 : 1 - (time - WHIP_SETTLE) / (1 - WHIP_SETTLE); + float step = attack.length * drawn / WHIP_LINKS; + Vec2 forward = vec2_polar(attack.aimAngle, 1); + Vec2 up = lash_direction_get(attack, PI / 2); + // Slack, it sags toward straight down: behind the hand while drawn back, ahead of it after the crack. + float rest = time < PATH_OUT ? WHIP_REAR : 0; + float down = rest + std::remainder(math_atan2(up.y, forward.y) - rest, 2 * PI); - // An even spiral bends at each point in a circle as wide as the square root of twice the whip left beyond it times - // its ring spacing over a full turn. - float turn = direction.x * up.y - direction.y * up.x > 0 ? 1 : -1; - float spacing = WHIP_COIL_GAP * attack.scale / (2 * PI); - float heading = attack.angle; - float step = coil / WHIP_COIL_STEPS; - Vec2 point = attack.tip; - for (int i = 0; i < WHIP_COIL_STEPS; i++) + std::vector points = {attack.position}; + for (int i = 0; i < WHIP_LINKS; i++) { - heading += turn * step / std::sqrt(2 * spacing * (coil - i * step)); - point = point + vec2_polar(heading, step); - points.push_back(point); + float along = (i + 0.5f) / WHIP_LINKS; + float heading = lash_heading_get(time - (PATH_OUT - WHIP_THROW) * math_pow(along, WHIP_TAPER)); + heading += kick * along * along; + heading += (down - heading) * WHIP_SAG * slack * along; + points.push_back(points.back() + lash_direction_get(attack, heading) * step); } return points; } @@ -465,7 +544,7 @@ static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) } // The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the -// hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip. +// hand, out as far ahead as its lash reaches and short of walls. Everything else is one capsule from position to tip. int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) { if (attack.def()->kind != ATTACK_WHIP || attack.is_charged()) @@ -476,7 +555,10 @@ int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; - float front = vec2_length(attack.tip - attack.origin); + float front = 0; + Vec2 forward = vec2_polar(attack.aimAngle, 1); + for (Vec2 point : lash_get(attack)) + front = std::max(front, vec2_dot(point - attack.origin, forward)); for (int ray = 0; ray < WHIP_RAYS; ray++) { float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1); @@ -748,7 +830,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) .isLeft = fighter.is_left(), .isSecondary = shot.isSecondary, }; - attack.position = attack.origin + aim * MUZZLE_DISTANCE; + attack.position = muzzle_get(attack); // Charging burns a grenade's fuse down (fully charged, it goes off in the barrel, see below). if (kind == ATTACK_GRENADE) @@ -808,8 +890,8 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) piece.aimAngle += offset; piece.velocity = vec2_polar(piece.angle, stats.speed + push); // A slash sets off a little ahead of the hand, clear of its fighter. - float muzzle = kind == ATTACK_SLASH ? SLASH_DISTANCE * piece.scale + SLASH_GAP : MUZZLE_DISTANCE; - piece.position = piece.origin + vec2_polar(piece.angle, muzzle); + float slash = SLASH_DISTANCE * piece.scale + SLASH_GAP; + piece.position = kind == ATTACK_SLASH ? piece.origin + vec2_polar(piece.angle, slash) : muzzle_get(piece); piece.isSecondary |= split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2; if (def.isOrbiting) @@ -843,10 +925,9 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) } sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); - Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE; float side = fighter.is_left() ? -1 : 1; if (weapon.flash > 0) - sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index); + sim.cues->flash_spawn(attack, weapon.flash); if (weapon.isEjecting) sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); @@ -1077,10 +1158,8 @@ void Weapons::attack_move(Sim& sim, Attack& attack) case ATTACK_WHIP: { // A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at - // once. A plain lash rolls out from the handle along its path and back, going with the hand, in one sweep - // across its cone: in from the upper edge as it unrolls, straight along the aim as it cracks at full reach - // (short of any wall), and on down to the lower edge as it comes back. It hits anywhere in the cone out to - // its front until it cracks. + // once. A plain lash goes with the hand, as long as its reach along the aim (short of any wall), its handle + // and links moving as lash_get says. It hits anywhere in its cone as far as it reaches until it cracks. if (attack.is_charged()) { attack.origin = fighter.hand_get(attack.hand); @@ -1092,15 +1171,12 @@ void Weapons::attack_move(Sim& sim, Attack& attack) auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; - float out = vec2_length(attack.position - attack.origin); - float unfurl = std::min(1.0f, progress / PATH_OUT); - float sweep = progress < PATH_OUT ? unfurl - 1 : (progress - PATH_OUT) / (1 - PATH_OUT); - attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * std::min(1.0f, sweep); - Vec2 reach = ray_march(attack.origin, attack.angle, reach_at_get(attack), isBlocked); + Vec2 reach = ray_march(attack.origin, attack.aimAngle, reach_at_get(attack), isBlocked); + attack.angle = vec2_angle(lash_direction_get(attack, lash_heading_get(progress))); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); - attack.length = std::max(0.0f, vec2_length(reach - attack.position)); - attack.tip = attack.position + vec2_polar(attack.angle, std::clamp(out - WHIP_HANDLE, 0.0f, attack.length)); - attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0; + attack.length = std::max(0.0f, vec2_length(reach - attack.origin) - WHIP_HANDLE); + attack.tip = lash_get(attack).back(); + attack.hitsLeft = progress < PATH_OUT ? attack.hitsLeft : 0; break; } case ATTACK_SPIN: @@ -1543,6 +1619,8 @@ void Weapons::damage_deal(Sim& sim, Damage damage) float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; attacker.hitstop_add(damage.hitstop); damage.knock *= attacker.stat_get(STAT_KNOCKBACK); + bool isCounted = damage.target != damage.owner && !(damage.target < MAX_FIGHTERS && sim.match.is_racing()); + attacker.damageDealt += isCounted ? damage.amount : 0; if (damage.target < MAX_FIGHTERS) sim.fighters[damage.target].damage_take(sim, damage); else @@ -1829,7 +1907,11 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); Vec2 velocity = isArrow ? aim * speed : aim; - Vec2 point = fighter.hand_get() + aim * MUZZLE_DISTANCE; + Attack probe = {.origin = fighter.hand_get(), + .angle = vec2_angle(aim), + .weapon = fighter.weaponLoadout[key], + .isLeft = fighter.is_left()}; + Vec2 point = muzzle_get(probe); int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; diff --git a/src/weapon.h b/src/weapon.h index 2340856..6d8b7f2 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -388,14 +388,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .spread = 5}, .levels = { - {.normal = {.damage = 14, .lifetime = 0.12f, .speed = 283}, - .charged = {.damage = 50, .lifetime = 0.35f, .lunge = 420, .scale = 1}, + {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 283}, + .charged = {.damage = 58, .lifetime = 0.35f, .lunge = 420, .scale = 1}, .cooldown = 0.1f}, - {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 333}, - .charged = {.damage = 69, .lifetime = 0.35f, .lunge = 500, .scale = 1.4f}, + {.normal = {.damage = 18, .lifetime = 0.12f, .speed = 333}, + .charged = {.damage = 80, .lifetime = 0.35f, .lunge = 500, .scale = 1.4f}, .cooldown = 0.07f}, - {.normal = {.damage = 19, .lifetime = 0.12f, .speed = 383}, - .charged = {.damage = 88, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f}, + {.normal = {.damage = 22, .lifetime = 0.12f, .speed = 383}, + .charged = {.damage = 102, .lifetime = 0.35f, .lunge = 580, .scale = 1.8f}, .cooldown = 0.045f}, }, .bot = {.rangeMax = 36}, @@ -431,14 +431,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.damage = 18, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, - .charged = {.damage = 36, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, + {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, + .charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, .cooldown = 0.3f}, - {.normal = {.damage = 24, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, - .charged = {.damage = 48, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, + {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, + .charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, .cooldown = 0.3f}, - {.normal = {.damage = 30, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, - .charged = {.damage = 60, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, + {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, + .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, .cooldown = 0.3f}, }, .bot = {.rangeMax = 48}, @@ -475,14 +475,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 20, .lifetime = 0.3f, .range = 21.43f, .scale = 1.07f, .speed = 258}, - .charged = {.damage = 40, .lifetime = 0.45f, .range = 45.71f, .scale = 2.29f, .speed = 367}, + {.normal = {.damage = 19, .lifetime = 0.3f, .range = 21.43f, .scale = 1.07f, .speed = 258}, + .charged = {.damage = 38, .lifetime = 0.45f, .range = 45.71f, .scale = 2.29f, .speed = 367}, .cooldown = 0.5f}, - {.normal = {.damage = 28, .lifetime = 0.3f, .range = 24.29f, .scale = 1.21f, .speed = 292}, - .charged = {.damage = 55, .lifetime = 0.45f, .range = 51.43f, .scale = 2.57f, .speed = 413}, + {.normal = {.damage = 27, .lifetime = 0.3f, .range = 24.29f, .scale = 1.21f, .speed = 292}, + .charged = {.damage = 53, .lifetime = 0.45f, .range = 51.43f, .scale = 2.57f, .speed = 413}, .cooldown = 0.5f}, - {.normal = {.damage = 34, .lifetime = 0.3f, .range = 27.14f, .scale = 1.36f, .speed = 327}, - .charged = {.damage = 69, .lifetime = 0.45f, .range = 57.14f, .scale = 2.86f, .speed = 459}, + {.normal = {.damage = 32, .lifetime = 0.3f, .range = 27.14f, .scale = 1.36f, .speed = 327}, + .charged = {.damage = 66, .lifetime = 0.45f, .range = 57.14f, .scale = 2.86f, .speed = 459}, .cooldown = 0.5f}, }, .bot = {.rangeMax = 36}, @@ -514,15 +514,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 15, .lifetime = 0.29f, .speed = 890}, - .charged = {.damage = 45, .lifetime = 0.29f, .lunge = 200, .speed = 890}, - .cooldown = 0.35f}, - {.normal = {.damage = 20, .lifetime = 0.31f, .speed = 970}, - .charged = {.damage = 60, .lifetime = 0.31f, .lunge = 250, .speed = 970}, - .cooldown = 0.35f}, - {.normal = {.damage = 25, .lifetime = 0.33f, .speed = 1040}, - .charged = {.damage = 75, .lifetime = 0.33f, .lunge = 300, .speed = 1040}, - .cooldown = 0.35f}, + {.normal = {.damage = 10, .lifetime = 0.29f, .speed = 890}, + .charged = {.damage = 28, .lifetime = 0.29f, .lunge = 200, .speed = 890}, + .cooldown = 0.55f}, + {.normal = {.damage = 14, .lifetime = 0.31f, .speed = 970}, + .charged = {.damage = 37, .lifetime = 0.31f, .lunge = 250, .speed = 970}, + .cooldown = 0.55f}, + {.normal = {.damage = 17, .lifetime = 0.33f, .speed = 1040}, + .charged = {.damage = 46, .lifetime = 0.33f, .lunge = 300, .speed = 1040}, + .cooldown = 0.55f}, }, .bot = {.rangeMin = 40, .rangeMax = 140}, }, @@ -556,15 +556,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isInstant = true}, .levels = { - {.normal = {.damage = 18, .lifetime = 0.38f, .speed = 950}, - .charged = {.area = 6, .damage = 42, .range = 125}, - .cooldown = 0.45f}, - {.normal = {.damage = 21, .lifetime = 0.38f, .speed = 1175}, - .charged = {.area = 7, .damage = 63, .range = 155}, - .cooldown = 0.45f}, - {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 1400}, - .charged = {.area = 8, .damage = 84, .range = 185}, - .cooldown = 0.45f}, + {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 950}, + .charged = {.area = 6, .damage = 46, .range = 125}, + .cooldown = 0.4f}, + {.normal = {.damage = 28, .lifetime = 0.38f, .speed = 1175}, + .charged = {.area = 7, .damage = 70, .range = 155}, + .cooldown = 0.4f}, + {.normal = {.damage = 32, .lifetime = 0.38f, .speed = 1400}, + .charged = {.area = 8, .damage = 93, .range = 185}, + .cooldown = 0.4f}, }, .bot = {.rangeMin = 90, .rangeMax = 160}, }, @@ -605,14 +605,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 13, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, - .charged = {.damage = 40, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450}, + {.normal = {.damage = 11, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, + .charged = {.damage = 35, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450}, .cooldown = 0.5f}, - {.normal = {.damage = 18, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, - .charged = {.damage = 50, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450}, + {.normal = {.damage = 15, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, + .charged = {.damage = 44, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450}, .cooldown = 0.5f}, - {.normal = {.damage = 22, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, - .charged = {.damage = 60, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, + {.normal = {.damage = 19, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, + .charged = {.damage = 51, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, .cooldown = 0.5f}, }, .bot = {.rangeMax = 60}, @@ -655,16 +655,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, - .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .cooldown = 0.2f, .count = 1}, - {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, - .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .cooldown = 0.2f, .count = 2}, - {.normal = {.damage = 12, .lifetime = 6, .range = 105, .speed = 600}, - .charged = {.damage = 20, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, + {.normal = {.damage = 10, .lifetime = 6, .range = 105, .speed = 600}, + .charged = {.damage = 17, .lifetime = 6, .lunge = -150, .range = 170, .speed = 850}, .cooldown = 0.2f, .count = 3}, }, @@ -708,17 +708,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isWielded = true}, .levels = { - {.normal = {.damage = 16, .range = 155, .speed = 600}, - .charged = {.damage = 32, .lifetime = 0.1f, .range = 250, .speed = 400}, - .cooldown = 0.85f, + {.normal = {.damage = 19, .range = 175, .speed = 600}, + .charged = {.damage = 35, .lifetime = 0.1f, .range = 250, .speed = 400}, + .cooldown = 0.7f, .reel = 120}, - {.normal = {.damage = 22, .range = 155, .speed = 700}, - .charged = {.damage = 45, .lifetime = 0.1f, .range = 300, .speed = 450}, - .cooldown = 0.85f, + {.normal = {.damage = 25, .range = 175, .speed = 700}, + .charged = {.damage = 48, .lifetime = 0.1f, .range = 300, .speed = 450}, + .cooldown = 0.7f, .reel = 135}, - {.normal = {.damage = 28, .range = 155, .speed = 800}, - .charged = {.damage = 58, .lifetime = 0.1f, .range = 350, .speed = 500}, - .cooldown = 0.85f, + {.normal = {.damage = 31, .range = 175, .speed = 800}, + .charged = {.damage = 61, .lifetime = 0.1f, .range = 350, .speed = 500}, + .cooldown = 0.7f, .reel = 150}, }, .bot = {.rangeMin = 60, .rangeMax = 200, .aim = AIM_LEAD}, @@ -763,14 +763,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isShot = true}, .levels = { - {.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, - .charged = {.damage = 22, .range = 500, .speed = 560, .volley = 1}, + {.normal = {.damage = 21, .range = 360, .speed = 320, .volley = 1}, + .charged = {.damage = 21, .range = 500, .speed = 560, .volley = 1}, .cooldown = 0.6f}, - {.normal = {.damage = 22, .range = 360, .speed = 360, .volley = 2}, - .charged = {.damage = 30, .range = 500, .speed = 670, .volley = 2}, + {.normal = {.damage = 21, .range = 360, .speed = 360, .volley = 2}, + .charged = {.damage = 29, .range = 500, .speed = 670, .volley = 2}, .cooldown = 0.6f}, - {.normal = {.damage = 22, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3}, - .charged = {.damage = 37, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3}, + {.normal = {.damage = 21, .fan = 0.12f, .range = 360, .speed = 400, .volley = 3}, + .charged = {.damage = 36, .fan = 0.12f, .range = 500, .speed = 780, .volley = 3}, .cooldown = 0.6f}, }, .bot = {.rangeMin = 100, .rangeMax = 260, .charge = CHARGE_ALWAYS, .aim = AIM_LEAD}, @@ -815,11 +815,11 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { {.normal = {.damage = 10, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, .charged = {.damage = 12, .lunge = -120, .range = RANGE_INFINITE}, .cooldown = 0.2f}, - {.normal = {.damage = 13, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, - .charged = {.damage = 24, .lunge = -120, .range = RANGE_INFINITE}, + {.normal = {.damage = 14, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 25, .lunge = -120, .range = RANGE_INFINITE}, .cooldown = 0.2f}, - {.normal = {.damage = 16, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, - .charged = {.damage = 36, .lunge = -120, .range = RANGE_INFINITE}, + {.normal = {.damage = 17, .fade = 0.64f, .lunge = -40, .range = RANGE_INFINITE, .speed = 850}, + .charged = {.damage = 37, .lunge = -120, .range = RANGE_INFINITE}, .cooldown = 0.2f}, }, .bot = {.rangeMin = 80, .rangeMax = 320, .aim = AIM_LEAD}, @@ -862,8 +862,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isShot = true}, .levels = { - {.normal = {.damage = 2, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 4, + {.normal = {.damage = 4.5f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 4.5f, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -871,8 +871,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .speed = 1200, .volley = 5}, .cooldown = 0.12f}, - {.normal = {.damage = 4, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 8, + {.normal = {.damage = 6.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 9, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -880,8 +880,8 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .speed = 1200, .volley = 7}, .cooldown = 0.075f}, - {.normal = {.damage = 6, .fade = 0.68f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, - .charged = {.damage = 12, + {.normal = {.damage = 8.25f, .fade = 1.6f, .lunge = -15, .range = RANGE_INFINITE, .speed = 800}, + .charged = {.damage = 14, .fade = 0.45f, .fan = 0.2f, .lunge = -60, @@ -934,15 +934,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isShot = true}, .levels = { - {.normal = {.damage = 6, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5}, - .charged = {.damage = 28, .lunge = -330, .range = 250, .speed = 900}, - .cooldown = 1.0f}, - {.normal = {.damage = 8, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6}, - .charged = {.damage = 37, .lunge = -330, .range = 250, .speed = 900}, - .cooldown = 1.0f}, - {.normal = {.damage = 10, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7}, - .charged = {.damage = 46, .lunge = -330, .range = 250, .speed = 900}, - .cooldown = 1.0f}, + {.normal = {.damage = 5.75f, .fan = 0.36f, .lunge = -180, .range = 115, .speed = 700, .volley = 5}, + .charged = {.damage = 26, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, + {.normal = {.damage = 7.5f, .fan = 0.36f, .lunge = -180, .range = 125, .speed = 700, .volley = 6}, + .charged = {.damage = 35, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, + {.normal = {.damage = 9.5f, .fan = 0.36f, .lunge = -180, .range = 135, .speed = 700, .volley = 7}, + .charged = {.damage = 43, .lunge = -330, .range = 250, .speed = 900}, + .cooldown = 1.1f}, }, .bot = {.rangeMax = 90, .aim = AIM_LEAD}, .isEjecting = true, @@ -989,15 +989,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.damage = 10, .range = 660, .speed = 260}, - .charged = {.damage = 24, .range = 660, .speed = 220}, - .cooldown = 0.6f}, - {.normal = {.damage = 13, .range = 840, .speed = 260}, - .charged = {.damage = 32, .range = 840, .speed = 220}, - .cooldown = 0.6f}, - {.normal = {.damage = 16, .range = 1020, .speed = 260}, - .charged = {.damage = 40, .range = 1020, .speed = 220}, - .cooldown = 0.6f}, + {.normal = {.damage = 7.5f, .range = 660, .speed = 260}, + .charged = {.damage = 19, .range = 660, .speed = 220}, + .cooldown = 0.65f}, + {.normal = {.damage = 9.25f, .range = 840, .speed = 260}, + .charged = {.damage = 24, .range = 840, .speed = 220}, + .cooldown = 0.65f}, + {.normal = {.damage = 12, .range = 1020, .speed = 260}, + .charged = {.damage = 31, .range = 1020, .speed = 220}, + .cooldown = 0.65f}, }, .bot = {.rangeMin = 30, .rangeMax = 200, .aim = AIM_FEET}, }, @@ -1042,14 +1042,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.area = 28, .damage = 14, .lunge = -120, .range = 500, .speed = 120}, - .charged = {.area = 46, .damage = 29, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + {.normal = {.area = 28, .damage = 13, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 46, .damage = 27, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .cooldown = 0.9f}, - {.normal = {.area = 35, .damage = 18, .lunge = -120, .range = 500, .speed = 120}, - .charged = {.area = 58, .damage = 39, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + {.normal = {.area = 35, .damage = 17, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 58, .damage = 36, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .cooldown = 0.9f}, - {.normal = {.area = 42, .damage = 22, .lunge = -120, .range = 500, .speed = 120}, - .charged = {.area = 69, .damage = 50, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, + {.normal = {.area = 42, .damage = 20, .lunge = -120, .range = 500, .speed = 120}, + .charged = {.area = 69, .damage = 47, .lunge = -220, .range = 600, .scale = 2, .speed = 60}, .cooldown = 0.9f}, }, .bot = {.rangeMin = 80, .rangeMax = 220, .aim = AIM_GROUND}, @@ -1103,15 +1103,15 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.area = 32, .damage = 30, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, - .charged = {.area = 32, .damage = 30, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, - .cooldown = 0.9f}, - {.normal = {.area = 38, .damage = 38, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, - .charged = {.area = 38, .damage = 38, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, - .cooldown = 0.9f}, - {.normal = {.area = 44, .damage = 46, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, - .charged = {.area = 44, .damage = 46, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, - .cooldown = 0.9f}, + {.normal = {.area = 32, .damage = 34, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 32, .damage = 34, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, + {.normal = {.area = 38, .damage = 43, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 38, .damage = 43, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, + {.normal = {.area = 44, .damage = 52, .fade = 1.59f, .lifetime = 0.75f, .lunge = -60, .speed = 340}, + .charged = {.area = 44, .damage = 52, .fade = 1.59f, .lifetime = 0.75f, .lunge = -140, .speed = 340}, + .cooldown = 0.8f}, }, .bot = {.rangeMin = 60, .rangeMax = 220, .charge = CHARGE_PLANNED, .aim = AIM_LEAD}, .isChargeScaled = true, @@ -1139,16 +1139,16 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isDrifting = true}, .levels = { - {.normal = {.area = 56, .damage = 55, .range = 120}, - .charged = {.area = 56, .damage = 55, .lifetime = 15, .range = 120, .speed = 720}, + {.normal = {.area = 56, .damage = 56, .range = 120}, + .charged = {.area = 56, .damage = 56, .lifetime = 15, .range = 120, .speed = 720}, .cooldown = 0.3f, .count = 2}, - {.normal = {.area = 66, .damage = 77, .range = 120}, - .charged = {.area = 66, .damage = 77, .lifetime = 15, .range = 120, .speed = 720}, + {.normal = {.area = 66, .damage = 78, .range = 120}, + .charged = {.area = 66, .damage = 78, .lifetime = 15, .range = 120, .speed = 720}, .cooldown = 0.3f, .count = 4}, - {.normal = {.area = 76, .damage = 99, .range = 120}, - .charged = {.area = 76, .damage = 99, .lifetime = 15, .range = 120, .speed = 720}, + {.normal = {.area = 76, .damage = 101, .range = 120}, + .charged = {.area = 76, .damage = 101, .lifetime = 15, .range = 120, .speed = 720}, .cooldown = 0.3f, .count = 6}, }, @@ -1192,14 +1192,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.damage = 1.5f, .lifetime = 1.5f, .range = 115, .speed = 500}, - .charged = {.area = 40, .damage = 30, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, + {.normal = {.damage = 1.75f, .lifetime = 1.5f, .range = 115, .speed = 500}, + .charged = {.area = 40, .damage = 31, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, .cooldown = 0.08f}, - {.normal = {.damage = 2.25f, .lifetime = 2.25f, .range = 140, .speed = 650}, - .charged = {.area = 55, .damage = 42, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, + {.normal = {.damage = 2.5f, .lifetime = 2.25f, .range = 140, .speed = 650}, + .charged = {.area = 55, .damage = 43, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, .cooldown = 0.08f}, - {.normal = {.damage = 3, .lifetime = 3, .range = 165, .speed = 800}, - .charged = {.area = 70, .damage = 54, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, + {.normal = {.damage = 3.25f, .lifetime = 3, .range = 165, .speed = 800}, + .charged = {.area = 70, .damage = 56, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, .cooldown = 0.08f}, }, .bot = {.rangeMax = 110}, @@ -1212,18 +1212,36 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .flavor = "Who ya gonna call?", .hit = HIT_AIR, .kick = -0.1f, - .normal = {.label = "suck", .kind = ATTACK_VACUUM, .sound = SOUND_NONE, .anchor = ANCHOR_HAND, .path = PATH_OWN, .shape = SHAPE_CONE, .knock = 0, .hits = 0, .isRigid = true}, - .charged = {.label = "airblast", .kind = ATTACK_GUST, .sound = SOUND_VACUUM_HEAVY, .anchor = ANCHOR_HAND, .path = PATH_OWN, .shape = SHAPE_CONE, .knock = 0, .shake = 0.15f, .hits = 0, .isClaiming = true, .isRigid = true}, + .normal = {.label = "suck", + .kind = ATTACK_VACUUM, + .sound = SOUND_NONE, + .anchor = ANCHOR_HAND, + .path = PATH_OWN, + .shape = SHAPE_CONE, + .knock = 0, + .hits = 0, + .isRigid = true}, + .charged = {.label = "airblast", + .kind = ATTACK_GUST, + .sound = SOUND_VACUUM_HEAVY, + .anchor = ANCHOR_HAND, + .path = PATH_OWN, + .shape = SHAPE_CONE, + .knock = 0, + .shake = 0.15f, + .hits = 0, + .isClaiming = true, + .isRigid = true}, .levels = { - {.normal = {.damage = 2, .range = 110, .speed = 160}, - .charged = {.damage = 6, .lifetime = 0.2f, .lunge = -120, .range = 110, .speed = 2100}, + {.normal = {.damage = 1.75f, .range = 110, .speed = 160}, + .charged = {.damage = 7.25f, .lifetime = 0.2f, .lunge = -120, .range = 110, .speed = 2100}, .cooldown = 0.2f}, - {.normal = {.damage = 3, .range = 135, .speed = 190}, - .charged = {.damage = 9, .lifetime = 0.2f, .lunge = -120, .range = 135, .speed = 2600}, + {.normal = {.damage = 2.75f, .range = 135, .speed = 190}, + .charged = {.damage = 11, .lifetime = 0.2f, .lunge = -120, .range = 135, .speed = 2600}, .cooldown = 0.2f}, - {.normal = {.damage = 5, .range = 160, .speed = 220}, - .charged = {.damage = 15, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100}, + {.normal = {.damage = 4.75f, .range = 160, .speed = 220}, + .charged = {.damage = 17, .lifetime = 0.2f, .lunge = -120, .range = 160, .speed = 3100}, .cooldown = 0.2f}, }, .bot = {.rangeMax = 110, .charge = CHARGE_ALWAYS}, @@ -1262,17 +1280,17 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.15f}, .levels = { - {.normal = {.damage = 12, .range = 320, .speed = 248, .volley = 1}, - .charged = {.area = 30, .damage = 20, .range = 220}, - .cooldown = 0.7f, + {.normal = {.damage = 14, .range = 320, .speed = 248, .volley = 1}, + .charged = {.area = 30, .damage = 27, .range = 220}, + .cooldown = 0.6f, .limit = 3}, - {.normal = {.damage = 14, .range = 320, .speed = 263, .volley = 2}, - .charged = {.area = 40, .damage = 30, .range = 250}, - .cooldown = 0.7f, + {.normal = {.damage = 16, .range = 320, .speed = 263, .volley = 2}, + .charged = {.area = 40, .damage = 39, .range = 250}, + .cooldown = 0.6f, .limit = 6}, - {.normal = {.damage = 15, .range = 320, .speed = 278, .volley = 3}, - .charged = {.area = 50, .damage = 40, .range = 280}, - .cooldown = 0.7f, + {.normal = {.damage = 18, .range = 320, .speed = 278, .volley = 3}, + .charged = {.area = 50, .damage = 50, .range = 280}, + .cooldown = 0.6f, .limit = 9}, }, .bot = {.rangeMin = 60, .rangeMax = 260}, @@ -1432,6 +1450,7 @@ float blast_area_get(Attack& attack); float strength_get(Attack& attack, Vec2 point); float fill_get(float strength); std::vector lash_get(Attack& attack); +Vec2 muzzle_get(Attack& attack); int shapes_get(Sim& sim, Attack& attack, Capsule* shapes); // One hit's worth of damage and the feedback that goes with it. Source is where it came from, which a fighter gets