From 6f112ab231a1315595c8fc6d532846c44cb9f025 Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 16:43:45 -0500 Subject: [PATCH] HUD pass, weapon and trinket rework, and font arrows HUD: - Hotbars fit a quarter of the screen, dropping to small then tiny slots (thinner outlines when tiny), with cooldown bars matching their size. - HUD elements are drawn on their own layer and composited at one opacity: hotbar slots are see-through until hovered, the rest fade while hovered. - Rival windows fit the right column in at most two rows, shrinking icons then font, then dropping icons for name, health and ping bars. - Tooltips number equipped items' slots and say Recoil for weapons that only push back. Previews use arrows, spread uses degrees. - Crosshair fills in and shakes with charge, and weapons shake after firing until their cooldown ends. DPS and speed meters off by default. Gameplay: - Dual Wield stacks: each copy fires another recently held weapon from a fanned off-hand, at its own level's cut. - Flamethrower: no slowing, smaller fireball with some recoil, and an airblast that knocks others away. - Iron Weights slam on any landing faster than a plain fall (platforms too), stacking radius and damage, and speeding fast falls. - Stuck arrows are one-way ledges until they fade, matching their (slightly larger) hit size. Attacks now collide with slopes. - Halved Tesla Coil, Recoil Coil, Diazepam, Shock Absorber, Feather and screenshake values. Art and tools: - Weapon and trinket icons merged into one sheet each (normal, small, tiny), cells in catalog order. Grimoire wisps stand upright. - Fonts gain the degree sign and four arrows, decoded from UTF-8. - Menu fighter preview walks at quarter speed. - Unequip from either catalog tab by right click or gamepad. Co-Authored-By: Claude Opus 5.5 --- resources/font/big.fnt | 7 +- resources/font/big.png | Bin 2186 -> 2324 bytes resources/font/huge.fnt | 7 +- resources/font/huge.png | Bin 2823 -> 3058 bytes resources/font/small.fnt | 7 +- resources/font/small.png | Bin 1673 -> 1704 bytes resources/font/tiny.fnt | 7 +- resources/font/tiny.png | Bin 798 -> 829 bytes resources/gfx/dummy.png | Bin 279 -> 9064 bytes resources/gfx/effects.anm2 | 33 +- resources/gfx/effects.png | Bin 2279 -> 10873 bytes resources/gfx/modes.png | Bin 347 -> 9128 bytes resources/gfx/player.anm2 | 14 + resources/gfx/tiles.png | Bin 613 -> 9250 bytes resources/gfx/trinkets.anm2 | 352 ++++++---------- resources/gfx/trinkets.png | Bin 12041 -> 6391 bytes resources/gfx/trinkets_small.anm2 | 567 -------------------------- resources/gfx/trinkets_small.png | Bin 1976 -> 0 bytes resources/gfx/trinkets_tiny.anm2 | 567 -------------------------- resources/gfx/trinkets_tiny.png | Bin 697 -> 0 bytes resources/gfx/ui.anm2 | 42 +- resources/gfx/ui.png | Bin 8972 -> 8998 bytes resources/gfx/weapon_icons.anm2 | 260 +++++------- resources/gfx/weapon_icons.png | Bin 10989 -> 4446 bytes resources/gfx/weapon_icons_small.anm2 | 419 ------------------- resources/gfx/weapon_icons_small.png | Bin 1325 -> 0 bytes resources/gfx/weapon_icons_tiny.anm2 | 419 ------------------- resources/gfx/weapon_icons_tiny.png | Bin 504 -> 0 bytes src/effects.cpp | 27 +- src/effects.h | 11 + src/fighter.cpp | 130 +++--- src/fighter.h | 12 +- src/menu.cpp | 52 ++- src/menu.h | 1 + src/renderer.cpp | 42 +- src/renderer.h | 5 + src/scene.cpp | 97 +++-- src/scene.h | 5 +- src/settings.h | 4 +- src/sim.h | 2 + src/sprites.cpp | 4 - src/sprites.h | 7 +- src/state.cpp | 1 + src/text.cpp | 52 ++- src/text.h | 5 +- src/trinket.h | 24 +- src/ui.cpp | 543 ++++++++++++++---------- src/ui.h | 43 +- src/weapon.cpp | 141 ++++--- src/weapon.h | 92 +++-- src/world.cpp | 27 +- src/world.h | 6 +- tools/fonts_export.py | 29 +- tools/icons_resize.py | 133 ------ 54 files changed, 1150 insertions(+), 3046 deletions(-) delete mode 100644 resources/gfx/trinkets_small.anm2 delete mode 100644 resources/gfx/trinkets_small.png delete mode 100644 resources/gfx/trinkets_tiny.anm2 delete mode 100644 resources/gfx/trinkets_tiny.png delete mode 100644 resources/gfx/weapon_icons_small.anm2 delete mode 100644 resources/gfx/weapon_icons_small.png delete mode 100644 resources/gfx/weapon_icons_tiny.anm2 delete mode 100644 resources/gfx/weapon_icons_tiny.png delete mode 100644 tools/icons_resize.py diff --git a/resources/font/big.fnt b/resources/font/big.fnt index 0d7b6a8..b6218c1 100644 --- a/resources/font/big.fnt +++ b/resources/font/big.fnt @@ -98,6 +98,11 @@ "{": {"x": 253, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, "|": {"x": 276, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 6}, "}": {"x": 299, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, - "~": {"x": 322, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14} + "~": {"x": 322, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "°": {"x": 345, "y": 145, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 10}, + "←": {"x": 0, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "↑": {"x": 23, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "→": {"x": 46, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14}, + "↓": {"x": 69, "y": 174, "w": 22, "h": 28, "dx": -2, "dy": 0, "advance": 14} } } diff --git a/resources/font/big.png b/resources/font/big.png index 4296bbbbbf6a6d52ee5a5ee5a50e606d501da0ac..a64738ca3b780834ca7a998b91bd3875252c2f67 100644 GIT binary patch literal 2324 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 A&Hw-a diff --git a/resources/font/huge.fnt b/resources/font/huge.fnt index d7c1488..f8add34 100644 --- a/resources/font/huge.fnt +++ b/resources/font/huge.fnt @@ -98,6 +98,11 @@ "{": {"x": 374, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, "|": {"x": 408, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 9}, "}": {"x": 442, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, - "~": {"x": 476, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21} + "~": {"x": 476, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "°": {"x": 510, "y": 215, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 15}, + "←": {"x": 0, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "↑": {"x": 34, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "→": {"x": 68, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21}, + "↓": {"x": 102, "y": 258, "w": 33, "h": 42, "dx": -3, "dy": 0, "advance": 21} } } diff --git a/resources/font/huge.png b/resources/font/huge.png index 00e2c10b27ea7f33de027157fa9a9c182cf4f4b6..c8a79f4bf864c6885fe2c7c126bbb80406bbae0c 100644 GIT binary patch literal 3058 zcmaJ@eOOXy7QdNVR+~*enl)lJGiG6yMELjtnl1UArgFth)U?qYKW4H})I`(WW;6$< zaVhb06BB`e{LC-Vc2Y{sQbSBKCS6lOv_?rq%Y~h~w)4!h?;kw(Ue0^YdC%|nJLkFxTLO}jP$0Pq)gH>@83Xqf@PI;)Mk>Mw{@NhknpZgR&u<4D7bk-qqfMkAdkUHev7 zyNSEGN6J%%M-np>S^U9)+~)0DuY57+XdH4#y%AbfGck$l(&xx+(p95nr6K2Ty@G}U zqhf!fM38Y`uM8+b6Mgaa(rNDOQ>WUbEx=Ho-bP^C0;VNH8r^{z?Go%e@R{@7284=M ziz<00wtUREFK~R=W8)@ov7S@iRA{Z5uUiLil6z z$7hoiyT#R;t}xu|e9g^(qs1Ec44pXu&#`6JY%=4FW-(`moyMNOc-v!$5J{R0^I-D) z3;;O4S8W}FtY~Z~ zAPtG{!XHRWg3$j=JSZX@f$7gxqf_tB-J-I)xgXTPmwc zAn<;Ju@9Rf{q!M2Cwb2{;K`+HV|~;^+vg7& ze@H#XW&hN^N?~;+O;T!S3d)Q*%C5qcPBV-tGOv4*gkwf4$FzK&s3<0FJ1}KSdQj}N z`xZ!g!S(sKaEdF{p373|Ng7!c3sG~Idei!Vn>y=(1HWjBvs_AOpDk`;uX=!^LvUgp z&Mw685jv659SjbmL0Oew3>AzJ<$0DnLcXc7_vW8-9Q`|}c1U?uLMsbf2+Gbiu_=Se zC)3am*a@&A=IDYsKkm~VIGnjld>qg4CD@dTM(9qxnPeU!xGIeT!_h8uMGG!^4~|J! zp)OX^#7f#%pHOTA_{A@9V(y?=nYBvWCFL8@GlTuz3ewo`N#7$Xp*-(6AYFS4+m$VB4o)u04l2 zkB53)ymZY^yp^z6bE_{-3JO7G2`Bnhb>K}3_5@vNE;z)A%O$_(+DCdkl{E(Ah1BJf zu#iQHwsMy{*-Cl~TDzHvg5FH7lPq?a# zok7m*F;z<8d+*+rR7*(DFO9sE4y*VhyRJPliSkVkp^9|SH!e*2H!Er^A%uwaVceAO zt{R)Znj0BY_9re4knyKJtF?y=P^Ybv&|Kb}cvRASS@V0SSYd(H%efeK=LUY)(g-cZy&8?O;yWa7$7vUJws4vDX#xM7D#d9;E9q4z3y+2{ zO7Hy+HkM>4jXQ%@ed7^Z6xH`Oa}}B<&-<^vhFckDSf0~w*ja>z=#YIKu&I`JH5rYa zA7R}t%gu*;UZ-3KGHf(zwp6S656fCA%ewLIyv zqbXMR!*G%cSM6NR2q&i(rK5)zdGPZNxPDcU7Vm~SP(iyU+H(B6tjR5;a(EmMPCN=i z-DY`a=}FJN>d~xN^bo`Qbo?vQe5^wnH`nwAZNejEOJcM4?O-`|dH4eR-elBv;#ZxO z<6ZWjqDNyIF7#YF8Kk-#NAC-x+RylXjb;fe!GN0N_LsHO7Z0Qin?*V1SB-TcY(1Xt zfuQ+KJK=}3W0w^c8@xsiBYM`r{MpL5_ZiB2Bs%ov0sSBT zcgH+73wcYrjIdWvqQ-8TeL(I%*d{wlJsl!Lk*fkoY2FpO`eK88hJLVxqfW_~xVa_2xY2Udye|3)4TE+8#8Qv0hwuJrOX3gT4A7q!=xe&`e zNmpo!?{heLIjBb_S%YLLk$)rO!-eDd!n78N5)iBh(4i_W^kwe+aVYAAT}c&_IPqVpDj*l;14X`t@!qk z?JmNZ0@FNg$U6ESx_xr}t=puGrN{e$CCLx!zY!G=K0l~fj- z?dYQJH-f$m8)t^KhN45G<#zf3PI%~)YNazx_w#1lebcEes(~QXqt+q;D3qzoG$Hh* ziV)Uof_DrTSa2Fhew?FLEg?>kGf5o?Uf_T?dU1ZgrKlff*3vs;J$*~y@D4+TnruOTZ_zxVHn|YiO!nGKRE&b-1+C~x)JkuJMGs} Pw8p^wxHp!2EG+XsQKJ+% literal 2823 zcmaJ@d00~07C)9{Ib`A0Yd(j*zN;2qEus-YtyI!{n!^!O(e5LMaws!}D9Nw;8YC}e zMQKjukTRNuQk{XgfV196lxS- zg3f`xQMrZlw+DW>FP5L>>r8nrSO7fCNCtp7%b@ijuUmpAG)u<4yn-T&P=nOh$_Yxa z2yUEXC2^(CDOs%ooU)9>3xmtL3mV|bJgWY!N*}thpnEHB?+V|V?4Ysi6UMH{9EoNEHM!)i%kdQs$xe z?iq0X`wNO)e~hR#e}*RkP5XxJnrY>!cc+~Ir%@Lg7FWCLMb$nadFvd6+?9m|`RV4O z>*29gmfpT}6uGlZKz{T#QZ>B+WsXHg1ttmV40w%cvSX6b46j{I-_8x%I%KRA@(S*4 zsZ=;8f&IPSVQAREwc(fnKP~nSoy9=FnYny_#8f3aZi~mw(wPrS!Tw0b9Rd7$g>v9p z1(SD-*&-o?pXZ9GoOf59lVECZ8s|c=={VI_5tMX`Y7-yc<{fqtOxeh4ksQI9ab!0p zNEdNEsLj5@QOlZ`@9;<0`}tYA#>SKw{tZ?fpK`~%S&J~Y#YhecKi=;wQ;d;PU8O`u z(~e>vtSYM416d+Ry3I%8sq@~^FGW{dv-ykbuyVR1#Qv{sGN!3Tf21^_Rpc$4?%uv1s2lN68!r!M`}A--sn3PV z(uuAnTSH}J$q_c2QajcX$D5!qYS;NkM;)da=>oUWb4Yk8)F=r zCYLY|X6y9H4@_ibeg5bVmHz|V^nT(@N#w@WncEukW~$*V9g>~sMb%Mh7!Ha>ey~1R z_0|Z17JulJshSc$gRCTUyo zed+x3#IY=Uhw`@G+JNbo(Q&UEH;o>m&aI6kvBF_v4x@o&n$2N=Soud9g`z?YGW!^^G;Y2~EXR_iDFQQF zq$s~9pjlk%_9-G!1E`~XiAb5FuX4Ud6uRaMwyX#(6v;daPK25DE%}%KQS>_Q!Kj=v zc|~AYD34!5w*TI}@V4nZR^IJanavHm_ADYN@vMBT3LbM)^GALirWs!jEySHITRonW zi_ZoL)gdwk-7pCJ5#V!s-V};<@}4!1bv_aJ+U%0{ba~ofA}{|4op|FY?X85E_Zz>9 zNf6o>^)-)DQpWf}xo-Cefw>iz_)p8Txj4+5Ln_2Y=p0Ocb(Am>3@JIW+0~$@?dbb} z%#O{~@@iv@XPNdNhaex+tz%_PJpRgVOVFI%f|_CR)=EN>YJ#5H!4nx!k$@r<+?|wF=4Z&@VlWijP`71NgP##jQ0-HyaBo zf!)~MfK%oimkrfi{)eP16;HxA)_I(n4LL?KL}R*Q@)Qd`8Df^u|56ovSBTqYmuG8u`4uX_!ezzokOR+*#1iqE z((XT_(JzZUgJR#4#-i<$*r|ipGDPlV-NZ`IRI(2zqRc;GJhh%KJ9>*&RZvm{B~Hyw zaSQc$xWgLT={mk7^0vDfk+H$NmCy{g=|I07+MD?5S4_-BWUgCFT$@ zact4cU$%Pbq;^1T#@$)uJI$pWJmOIo!6T$^hdfn}|E#-FS(VMi(|6IUXg{53HqdLl zLQ#r(Nq!8OT||EX!iRe&lw_w!PkJ^rxq6T zV(pk;d#%6e{sW4y%j8AJw!`YqG_7l7jpWtB6W-$2f90!F*5i~5rUVa(CHjGQe5bGt zE4-cvU0bf~STtnHDTe{B-`fjk5z?ged3AhcPs8;<8w~ul8wF>Ek?t-z*SB)^p##~+ zj5sx5Enqc}#O(aDpz(kclnFd*U!bH1FPY3(qOwEclEu-V{SIK_Xj-D4vPT^UH&8Z_ zWAHsEt|rgFhqTg?Zn(cIwA^8d;k;`5fV&n_({@*p{YYS$bi7{DtNp+b@=HCYG+Zs# zlr@+&4edut?Km;zp4F_T91}dEtYPL7WK#XaVw*p}fOOCzKcN$R)4NcU7^5*ThCd+R zw#?trA4+a}>nVo$+LA#x@00YZiRQpf&#E(PK~X>F0#zjPrne&2XF^5s`aiYS96rg1 z<78VE;uua*QVAiYx3Zxb+W6<=LrrYG}%{ diff --git a/resources/font/small.fnt b/resources/font/small.fnt index f9d6022..3b2b6e2 100644 --- a/resources/font/small.fnt +++ b/resources/font/small.fnt @@ -98,6 +98,11 @@ "{": {"x": 132, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, "|": {"x": 144, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 3}, "}": {"x": 156, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, - "~": {"x": 168, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7} + "~": {"x": 168, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "°": {"x": 180, "y": 75, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 5}, + "←": {"x": 0, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "↑": {"x": 12, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "→": {"x": 24, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7}, + "↓": {"x": 36, "y": 90, "w": 11, "h": 14, "dx": -1, "dy": 0, "advance": 7} } } diff --git a/resources/font/small.png b/resources/font/small.png index 305401fb76fe3dfce0e816d774c739f291183899..48894685ccdb25773c6072670dc1f3850c556680 100644 GIT binary patch delta 1701 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 P00000NkvXXu0mjf8ZbJ- diff --git a/resources/font/tiny.fnt b/resources/font/tiny.fnt index 1ddad17..afa4f2f 100644 --- a/resources/font/tiny.fnt +++ b/resources/font/tiny.fnt @@ -97,6 +97,11 @@ "{": {"x": 66, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, "|": {"x": 72, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 2}, "}": {"x": 78, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, - "~": {"x": 84, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5} + "~": {"x": 84, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "°": {"x": 90, "y": 45, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "←": {"x": 0, "y": 54, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "↑": {"x": 6, "y": 54, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4}, + "→": {"x": 12, "y": 54, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 5}, + "↓": {"x": 18, "y": 54, "w": 5, "h": 8, "dx": 0, "dy": 0, "advance": 4} } } diff --git a/resources/font/tiny.png b/resources/font/tiny.png index b84e6eff77e421554404b167eee6873a8d05a18f..2e3817f4ad9b5e0631430239893bd4744430ad60 100644 GIT binary patch delta 819 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 delta 252 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 P00000NkvXXu0mjfIbK$H diff --git a/resources/gfx/modes.png b/resources/gfx/modes.png index 29557a4f92e7cfd61240fa7234b972642afd20f5..4790f88d776a5b7b1020239bd11184c904433b25 100644 GIT binary patch literal 9128 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 T-{L*P00000NkvXXu0mjfb^w{z diff --git a/resources/gfx/player.anm2 b/resources/gfx/player.anm2 index 3f092ce..8fc0187 100644 --- a/resources/gfx/player.anm2 +++ b/resources/gfx/player.anm2 @@ -58,6 +58,20 @@ } ] }, + "Preview": { + "length": 16, + "layers": [ + { + "layer": 0, + "frames": [ + {"region": 1, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, + {"region": 2, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, + {"region": 3, "scale": [-100, 100], "delay": 4, "interpolation": "linear"}, + {"region": 4, "scale": [-100, 100], "delay": 4, "interpolation": "linear"} + ] + } + ] + }, "Jump": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, "Fall": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, "Slide": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, diff --git a/resources/gfx/tiles.png b/resources/gfx/tiles.png index ff7e06f741a0d28f9c42e5b070108b6c9cdea00b..9d748843177ee4bfa92b33f9e06166075e5f4732 100644 GIT binary patch literal 9250 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 Eg1y0|kN^Mx literal 613 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 FV1jbaQcnN? literal 12041 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 diff --git a/resources/gfx/trinkets_small.anm2 b/resources/gfx/trinkets_small.anm2 deleted file mode 100644 index c1b4278..0000000 --- a/resources/gfx/trinkets_small.anm2 +++ /dev/null @@ -1,567 +0,0 @@ -{ - "anm2ed": 1, - "info": { - "fps": 60 - }, - "spritesheets": [ - { - "path": "trinkets_small.png", - "regions": [ - { - "name": "Gloves", - "crop": [ - 0, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Energy Drink", - "crop": [ - 16, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Accelerator", - "crop": [ - 32, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Enlarger", - "crop": [ - 48, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Boxing Glove", - "crop": [ - 64, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Splitter", - "crop": [ - 80, - 0 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Doubler", - "crop": [ - 0, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Dual Wield", - "crop": [ - 16, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Ricochet", - "crop": [ - 32, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Tesla Coil", - "crop": [ - 48, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Boots", - "crop": [ - 64, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Speedometer", - "crop": [ - 80, - 16 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Shrinker", - "crop": [ - 0, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Wings", - "crop": [ - 16, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Feather", - "crop": [ - 32, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Iron Weights", - "crop": [ - 48, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Climb Claw", - "crop": [ - 64, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Recoil Coil", - "crop": [ - 80, - 32 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Diazepam", - "crop": [ - 0, - 48 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Embiggener", - "crop": [ - 16, - 48 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Pebble Helmet", - "crop": [ - 32, - 48 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - }, - { - "name": "Shock Absorber", - "crop": [ - 48, - 48 - ], - "size": [ - 16, - 16 - ], - "origin": "center" - } - ] - } - ], - "layers": [ - { - "name": "sprite" - } - ], - "defaultAnimation": "Gloves", - "animations": { - "Gloves": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 0 - } - ] - } - ] - }, - "Energy Drink": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 1 - } - ] - } - ] - }, - "Accelerator": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 2 - } - ] - } - ] - }, - "Enlarger": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 3 - } - ] - } - ] - }, - "Boxing Glove": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 4 - } - ] - } - ] - }, - "Splitter": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 5 - } - ] - } - ] - }, - "Doubler": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 6 - } - ] - } - ] - }, - "Dual Wield": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 7 - } - ] - } - ] - }, - "Ricochet": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 8 - } - ] - } - ] - }, - "Tesla Coil": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 9 - } - ] - } - ] - }, - "Boots": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 10 - } - ] - } - ] - }, - "Speedometer": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 11 - } - ] - } - ] - }, - "Shrinker": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 12 - } - ] - } - ] - }, - "Wings": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 13 - } - ] - } - ] - }, - "Feather": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 14 - } - ] - } - ] - }, - "Iron Weights": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 15 - } - ] - } - ] - }, - "Climb Claw": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 16 - } - ] - } - ] - }, - "Recoil Coil": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 17 - } - ] - } - ] - }, - "Diazepam": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 18 - } - ] - } - ] - }, - "Embiggener": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 19 - } - ] - } - ] - }, - "Pebble Helmet": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 20 - } - ] - } - ] - }, - "Shock Absorber": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 21 - } - ] - } - ] - } - } -} diff --git a/resources/gfx/trinkets_small.png b/resources/gfx/trinkets_small.png deleted file mode 100644 index 3fd15ab46e2b527a64f44dfceddf599aae9da8f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1976 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 literal 10989 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 diff --git a/resources/gfx/weapon_icons_tiny.anm2 b/resources/gfx/weapon_icons_tiny.anm2 deleted file mode 100644 index e986a8b..0000000 --- a/resources/gfx/weapon_icons_tiny.anm2 +++ /dev/null @@ -1,419 +0,0 @@ -{ - "anm2ed": 1, - "info": { - "fps": 60 - }, - "spritesheets": [ - { - "path": "weapon_icons_tiny.png", - "regions": [ - { - "name": "Sword", - "crop": [ - 0, - 0 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Hammer", - "crop": [ - 8, - 0 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Flail", - "crop": [ - 16, - 0 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Lance", - "crop": [ - 24, - 0 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Whip", - "crop": [ - 0, - 8 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Grapple", - "crop": [ - 8, - 8 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Boomerang", - "crop": [ - 16, - 8 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Bow", - "crop": [ - 24, - 8 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Blaster", - "crop": [ - 0, - 16 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Uzi", - "crop": [ - 8, - 16 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Shotgun", - "crop": [ - 16, - 16 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Flamethrower", - "crop": [ - 24, - 16 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Rocket Launcher", - "crop": [ - 0, - 24 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Mine", - "crop": [ - 8, - 24 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Fist", - "crop": [ - 16, - 24 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - }, - { - "name": "Grimoire", - "crop": [ - 24, - 24 - ], - "size": [ - 8, - 8 - ], - "origin": "center" - } - ] - } - ], - "layers": [ - { - "name": "sprite" - } - ], - "defaultAnimation": "Sword", - "animations": { - "Sword": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 0 - } - ] - } - ] - }, - "Hammer": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 1 - } - ] - } - ] - }, - "Flail": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 2 - } - ] - } - ] - }, - "Lance": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 3 - } - ] - } - ] - }, - "Whip": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 4 - } - ] - } - ] - }, - "Grapple": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 5 - } - ] - } - ] - }, - "Boomerang": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 6 - } - ] - } - ] - }, - "Bow": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 7 - } - ] - } - ] - }, - "Blaster": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 8 - } - ] - } - ] - }, - "Uzi": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 9 - } - ] - } - ] - }, - "Shotgun": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 10 - } - ] - } - ] - }, - "Flamethrower": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 11 - } - ] - } - ] - }, - "Rocket Launcher": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 12 - } - ] - } - ] - }, - "Mine": { - "length": 1, - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 13 - } - ] - } - ] - }, - "Fist": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 14 - } - ] - } - ] - }, - "Grimoire": { - "layers": [ - { - "layer": 0, - "frames": [ - { - "region": 15 - } - ] - } - ] - } - } -} diff --git a/resources/gfx/weapon_icons_tiny.png b/resources/gfx/weapon_icons_tiny.png deleted file mode 100644 index 34fdc0463dbbc011a8cb648910fb1a818c34dc40..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 504 zcmV ziJ2I|Fs8A3p}dVFPy)x>b^#z%d#$_U&EM0`n zae(hud(}mQ$?BU1bApD|F*D6M-HPm>iu_gIktRDlR`4;#FP8&Ks?Kf~+H}v@vchZo ziD+DJOEtJa7u+?z%f$Lyon=KMqI*AaE3myMRcH1gV=|ti&2|=rlz0cN{*xXP=$p2CF{MTByKVv3Tob(f$i~&~v+JY!>HS%$OWH+g3f~F@EGxzL7 zMCP1n^>lTQqApg{K)rpQbGnIH@Th=oDLRxUehg}>JaRrrWc$CMN@Ax{{8X#mD*=0& zbzObqiGF5?1+WFpIUjag{{={$hy@m)62)ln!sR+M^BCh*>FK!$`LE!VOgX69 hits; std::vector particles; std::vector popups; + std::vector rings; float shake = 0; int ticks = 0; }; diff --git a/src/fighter.cpp b/src/fighter.cpp index fae4c07..4fe8101 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -3,7 +3,7 @@ #include constexpr float AIR_ACCELERATION = 400; -constexpr float BOUNCE_SHAKE = 0.0005f; +constexpr float BOUNCE_SHAKE = 0.00025f; constexpr float SLAM_DAMAGE = 0.06f; constexpr float KNOCK_BASE = 60; constexpr float KNOCK_LIFT = 0.35f; @@ -17,11 +17,12 @@ constexpr float CLING_SPEED = 60; constexpr float COYOTE_TIME = 0.1f; constexpr float DASH_COOLDOWN = 0.8f; constexpr float DASH_TIME = 0.12f; -constexpr float DEATH_SHAKE = 1.5f; +constexpr float DEATH_SHAKE = 0.75f; constexpr float FACING_DEADZONE = 4; constexpr float FAST_FALL_GRAVITY = 2; constexpr float FLASH_DECAY = 8; constexpr float FAST_FALL_SPEED = 700; +constexpr float FALL_EASE = 2000; constexpr float GROUND_ACCELERATION = 500; constexpr float GROUND_FRICTION = 3000; constexpr float JUMP_BUFFER = 0.1f; @@ -34,13 +35,13 @@ constexpr float ROPE_MIN = 24; constexpr float SLIDE_DUST_INTERVAL = 0.08f; constexpr float SWING_ACCELERATION = 500; constexpr float SWING_SPEED_MAX = 420; -constexpr float POUND_AREAS[LEVEL_COUNT] = {30, 40, 50}; +constexpr float POUND_AREAS[LEVEL_COUNT] = {15, 20, 25}; constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50}; constexpr float POUND_SCALE_MAX = 3; -constexpr float POUND_SCALE_MIN = 0.3f; constexpr float POUND_SPEED = 700; constexpr Vec2 HAND_OFFSET = {4, 2}; constexpr Vec2 OFFHAND_OFFSET = {-3, 4}; +constexpr Vec2 OFFHAND_FAN = {3, 2}; constexpr float WALL_IMPACT_SPEED = 200; constexpr float WALL_LIFT = 0.25f; constexpr float WALL_MEMORY = 0.6f; @@ -118,15 +119,14 @@ void Fighter::state_reset(Sim& sim, float maxHealth) std::fill(std::begin(cooldowns), std::end(cooldowns), 0.0f); flash = 0; health = maxHealth; - offSlot = -1; + std::fill(std::begin(slotSwitches), std::end(slotSwitches), 0); slot = 0; isAlive = true; } // Loses health with a flash and hitstop, and gets knocked away from where the hit came from (and a little upward), -// up to a speed that grows with the damage. Knocks top up rather than stack, so rapid small hits can't juggle. A hard -// enough knock launches it. Shock Absorber softens it. Running out of health kills it in a burst, and its attacks -// vanish with it. +// up to a speed that grows with the damage (see knock_take). Shock Absorber softens it. Running out of health kills it +// in a burst, and its attacks vanish with it. void Fighter::damage_take(Sim& sim, Damage damage) { // Races and the sandbox never cost health. @@ -138,11 +138,8 @@ void Fighter::damage_take(Sim& sim, Damage damage) Vec2 away = center_get() - damage.source; Vec2 direction = vec2_normalize(vec2_normalize(vec2_length(away) > 1 ? away : Vec2{0, -1}) + Vec2{0, -KNOCK_LIFT}); float knock = std::min(KNOCK_MAX, KNOCK_BASE + damage.amount * KNOCK_PER_DAMAGE) * damage.knock * - (isCrouching ? CROUCH_DAMP : 1) * (isRacing ? RACE_KNOCK : 1) * knock_absorb_get(); - float along = vec2_dot(body.velocity, direction); - impulse_add(direction * std::max(0.0f, knock - along), false); - launcher = damage.owner; - isLaunched |= knock >= LAUNCH_START_SPEED; + (isRacing ? RACE_KNOCK : 1) * knock_absorb_get(); + knock_take(direction * knock, damage.owner); if (health > 0) return; @@ -212,12 +209,12 @@ void Fighter::update(Sim& sim) rope_update(*hook); bool wasGrounded = body.isGrounded; - bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS); float fallSpeed = body.velocity.y; float runSpeed = body.velocity.x; - sim.world.obstacles = sim.weapons.solids_get(*this); + sim.weapons.solids_add(*this, sim.world); sim.world.body_move(body); sim.world.obstacles.clear(); + sim.world.ledges.clear(); if (isLaunched) bounce_resolve(sim, {runSpeed, fallSpeed}); safe_update(sim); @@ -240,13 +237,14 @@ void Fighter::update(Sim& sim) sim.cues->sound_play(SOUND_LAND); sim.cues->burst_spawn(LAND_DUST, feet, 0, index); } - // Iron Weights slam: the harder the landing, the harder the blast. Damage follows the landing speed, and the - // radius its square root. - if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED && isPounding) + // Iron Weights slam: landing faster than a plain fall can go (on anything, platforms too), and the harder the + // landing, the harder the blast. Every copy adds to its damage and radius. Damage follows the landing speed, and + // the radius its square root. + if (body.isGrounded && !wasGrounded && fallSpeed > MAX_FALL && is_trinket_equipped(TRINKET_WEIGHTS)) { - float impact = std::clamp(fallSpeed / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX); + float impact = std::min(fallSpeed / POUND_SPEED, POUND_SCALE_MAX); sim.weapons.blast_spawn(sim, {.position = feet, - .area = POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact), + .area = trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact), .damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact, .owner = index, .sound = SOUND_POUND}); @@ -295,9 +293,10 @@ void Fighter::update(Sim& sim) buttonsPrevious = controls.buttons; } -// Horizontal control and gravity. Holding S in the air falls faster, holding W floats with the Feather, and a spinning -// flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer only to the slope. Crouching -// on flat ground waddles along slowly. +// Horizontal control and gravity. Holding S in the air falls faster (Iron Weights speeding up both how fast it gets +// there and how fast it goes), and letting go eases back to a plain fall rather than stopping short. Holding W floats +// with the Feather, and a spinning flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer +// only to the slope. Crouching on flat ground waddles along slowly. // Airborne on a rope, moving left and right pumps the swing instead. // Above run speed in the air, launch momentum bleeds off slowly unless steered against. Launched by a hit, there's no // control at all, just gravity and the knock dying down. @@ -320,11 +319,15 @@ void Fighter::run_update(Attack* hook) bool isAirborne = !body.isGrounded && !hook; bool isFastFalling = isAirborne && is_down(CONTROL_DOWN) && !isSliding; bool isFloating = isAirborne && is_down(CONTROL_UP); - float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY : 1); + float weights = isFastFalling ? trinket_get(TRINKET_WEIGHTS, 1) : 1; + float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1); gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 1; gravity *= isSpinning ? SPIN_GRAVITY : 1; - float maxFall = isFastFalling ? FAST_FALL_SPEED * trinket_get(TRINKET_WEIGHTS, 1) : MAX_FALL; - body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall); + float maxFall = isFastFalling ? FAST_FALL_SPEED * weights : MAX_FALL; + if (body.velocity.y > maxFall) + body.velocity.y = std::max(maxFall, body.velocity.y - FALL_EASE * TICK); + else + body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall); // Sliding: the slope pulls it downhill and little else slows it. if (isSliding) @@ -402,7 +405,7 @@ void Fighter::slot_update(Sim& sim) sim.cues->sound_play(SOUND_SELECT); if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot)) { - offSlot = previous; + slotSwitches[previous] = ++switches; sim.weapons.attacks_release(*this, ATTACK_FLAIL); } @@ -654,15 +657,14 @@ float Fighter::trinket_cooldown_get(int trinket) return 0; } -// The radius an Iron Weights slam would blast if the fighter, pounding, landed at its current speed, or 0 if it isn't -// pounding down fast enough to slam at all. +// The radius an Iron Weights slam would blast if the fighter landed at its current speed, or 0 if it isn't falling +// fast enough to slam at all. float Fighter::pound_area_get() { - bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS); - if (!isPounding || body.isGrounded || body.velocity.y <= LAND_SPEED) + if (!is_trinket_equipped(TRINKET_WEIGHTS) || body.isGrounded || body.velocity.y <= MAX_FALL) return 0; - float impact = std::clamp(body.velocity.y / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX); - return POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact); + float impact = std::min(body.velocity.y / POUND_SPEED, POUND_SCALE_MAX); + return trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact); } // Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports). @@ -681,6 +683,19 @@ void Fighter::impulse_add(Vec2 impulse, bool isDamped) isJumping = false; } +// A knock from another fighter's hit or blast, softened while crouching. It tops up the speed already going its way +// rather than stacking, so rapid small knocks can't juggle, and a hard enough one launches it (that fighter getting +// the credit for whatever it bounces into). +void Fighter::knock_take(Vec2 knock, int owner) +{ + knock = knock * (isCrouching ? CROUCH_DAMP : 1); + float strength = vec2_length(knock); + Vec2 direction = vec2_normalize(knock); + impulse_add(direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction)), false); + launcher = owner; + isLaunched |= strength >= LAUNCH_START_SPEED; +} + // A push held briefly without gravity or friction, so it carries even along the ground. Used by dashes and lunges. void Fighter::burst_add(Vec2 impulse) { @@ -827,28 +842,27 @@ bool Fighter::is_released(Control control) return !is_down(control) && buttonsPrevious >> control & 1; } -// Dual Wield's off-hand weapon slot: the last one switched away from if it still holds a weapon that isn't the one in -// hand, otherwise any other slot that does, or -1 if there is none. When weapons stack, a copy of the weapon in hand -// is another weapon; otherwise it's the same one. -int Fighter::off_slot_get() +// Dual Wield's off-hand weapon slots, one per copy, written to slots: the other slots holding weapons, the ones most +// recently switched away from first, then any never switched to, in slot order. Returns how many. +int Fighter::off_slots_get(int* slots) { int held = weapon_key(slot); - int fallback = -1; + int count = 0; for (int i = 0; i < weaponSlots; i++) - { - if (weaponLoadout[i] < 0 || weapon_key(i) == held) - continue; - if (i == offSlot) - return i; - fallback = fallback < 0 ? i : fallback; - } - return fallback; + if (weaponLoadout[i] >= 0 && weapon_key(i) != held) + slots[count++] = i; + std::stable_sort(slots, slots + count, [&](int a, int b) { return slotSwitches[a] > slotSwitches[b]; }); + return std::min(count, trinket_count(TRINKET_DUAL)); } -int Fighter::off_weapon_get() +// The weapon slot a hand holds: the selected one in the main hand (0), and Dual Wield's in the off-hands (1 up), or -1 +// for an off-hand holding nothing. +int Fighter::hand_slot_get(int hand) { - int off = off_slot_get(); - return off < 0 ? -1 : weaponLoadout[off]; + if (hand == 0) + return slot; + int slots[SLOT_MAX]; + return hand <= off_slots_get(slots) ? slots[hand - 1] : -1; } // Moves the box alpha of the way through the last tick. Only for drawing. @@ -863,15 +877,17 @@ Vec2 Fighter::center_get() return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2}; } -Vec2 Fighter::hand_get() +// Where a hand holds its weapon: the main hand (0) in front, and Dual Wield's off-hands (1 up) behind the body, the +// first just behind it and the rest fanned out further back, alternating up and down. +Vec2 Fighter::hand_get(int hand) { - return center_get() + Vec2{is_left() ? -HAND_OFFSET.x : HAND_OFFSET.x, HAND_OFFSET.y} * scale; -} - -// The far hand, just behind the body, where Dual Wield holds the off-hand weapon. -Vec2 Fighter::offhand_get() -{ - return center_get() + Vec2{is_left() ? -OFFHAND_OFFSET.x : OFFHAND_OFFSET.x, OFFHAND_OFFSET.y} * scale; + Vec2 offset = HAND_OFFSET; + if (hand > 0) + { + int step = hand / 2; + offset = OFFHAND_OFFSET + Vec2{-OFFHAND_FAN.x * step, (hand % 2 == 0 ? -1 : 1) * OFFHAND_FAN.y * step}; + } + return center_get() + Vec2{is_left() ? -offset.x : offset.x, offset.y} * scale; } // Full charge pulses white on and off, shared by the charge bar and the held weapon. diff --git a/src/fighter.h b/src/fighter.h index cd20783..ff8fe14 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -77,8 +77,10 @@ public: float contactCooldown = 0; int index = 0; int launcher = 0; - int offSlot = -1; int slot = 0; + // When each weapon slot was last switched away from, counting switches (0 for never), for Dual Wield. + int slotSwitches[SLOT_MAX] = {}; + int switches = 0; int trinketItemLevels[TRINKET_COUNT] = {}; int trinketSlotLevels[SLOT_MAX] = {}; int trinketLoadout[SLOT_MAX]; @@ -105,13 +107,14 @@ public: void update(Sim& sim); void blend(float alpha); void impulse_add(Vec2 impulse, bool isDamped = true); + void knock_take(Vec2 knock, int owner); void teleport_to(Vec2 centre); void burst_add(Vec2 impulse); void charge_cancel(Sim& sim); int weapon_get(); int weapon_key(int weaponSlot); - int off_slot_get(); - int off_weapon_get(); + int off_slots_get(int* slots); + int hand_slot_get(int hand); int slot_level_get(int weaponSlot); const LevelDef& level_get(int weaponSlot); void item_set(bool isTrinket, int itemSlot, int item); @@ -125,8 +128,7 @@ public: float knock_absorb_get(); float pound_area_get(); Vec2 center_get(); - Vec2 hand_get(); - Vec2 offhand_get(); + Vec2 hand_get(int hand = 0); bool is_any_pressed(); bool is_down(Control control); bool is_pressed(Control control); diff --git a/src/menu.cpp b/src/menu.cpp index 00af937..6b6add7 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -179,7 +179,7 @@ void Menu::update(Game& game) isPinnedTrinket = isTrinket; game.audio.play(SOUND_SELECT); } - if (isRight && kind == tab && isChoosable) + if (isRight && is_choosable(game, isTrinket)) slot_clear(game, isTrinket, index); } } @@ -400,7 +400,8 @@ void Menu::header_draw(Game& game, MenuLayout& layout) // An item tab: the item grid with its scroll bar, the selection's description (scrolling, back to its top for each // new selection), and the line between the two hotbars below. The focus moves the selection through the grid (scrolling -// it), and confirming equips or unequips it. +// it), and confirming equips or unequips it. It moves onto the hotbars too (drawn after), where a filled slot of +// either kind shows in the description, and confirming it clears it if it can be chosen. void Menu::items_draw(Game& game, MenuLayout& layout) { Renderer& renderer = game.renderer; @@ -432,6 +433,23 @@ void Menu::items_draw(Game& game, MenuLayout& layout) .isLit = i == selection, .isTrinket = tab == TAB_TRINKETS}); } + Fighter& fighter = game.local_get(); + for (int kind = 0; kind < ITEM_TABS; kind++) + { + bool isTrinket = kind == TAB_TRINKETS; + int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + for (int index = 0; index < count; index++) + { + if (!ui.focus_add(loadout, index, slot_rect(isTrinket, index, count)) || loadout[index] < 0) + continue; + pinnedSlot = index; + pinnedTab = tab; + isPinnedTrinket = isTrinket; + if (ui.isConfirmed && is_choosable(game, isTrinket)) + slot_clear(game, isTrinket, index); + } + } Rect track = layout.scroll; int scrollMax = std::max(0, row_count() - layout.rows); @@ -441,8 +459,8 @@ void Menu::items_draw(Game& game, MenuLayout& layout) renderer.rect_draw({track.x, std::round(headY), track.w, std::round(headHeight)}, ui.tint); renderer.rect_draw(layout.divider, ui.tint); - // The description is of the selection, or of a hotbar slot clicked on this tab while it still holds something. - Fighter& fighter = game.local_get(); + // The description is of the selection, or of a hotbar slot clicked (or focused) on this tab while it still holds + // something. bool isTrinket = tab == TAB_TRINKETS; int item = selections[tab]; int level = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item]; @@ -463,6 +481,8 @@ void Menu::items_draw(Game& game, MenuLayout& layout) description.scroll = 0; described = shown; Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level); + if (slotLevel) + tooltip.slot = pinnedSlot; Rect area = layout.description; bool isLevelable = game.sim.match.state == MATCH_PRACTICE; if (isLevelable) @@ -1043,18 +1063,30 @@ bool Menu::is_choosable(Game& game, bool isTrinket) return match.state == MATCH_COUNTDOWN && (isTrinket ? match.isTrinketPick : match.isWeaponPick); } -// Where a hotbar slot sits: closed, the weapon bar is top left and the trinket bar bottom left. On an item tab, -// both sit under the menu either side of its middle. +// Where a hotbar slot sits: closed, the weapon bar is top left and the trinket bar bottom left, each as large as fits +// its column. On an item tab, both sit under the menu either side of its middle, full size. Rect Menu::slot_rect(bool isTrinket, int index, int count) { - float step = SLOT_SIZE + SLOT_GAP; - Vec2 position = {HOTBAR_MARGIN + index * step, - isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE : HOTBAR_MARGIN}; + IconSize size = slot_size_get(count); + float slot = SLOT_SIZES[size]; + float step = slot + SLOT_GAPS[size]; + Vec2 position = {HOTBAR_MARGIN + index * step, isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - slot : HOTBAR_MARGIN}; if (is_bars_open()) position = {isTrinket ? CANVAS_WIDTH / 2.0f + SLOT_GAP + index * step : CANVAS_WIDTH / 2.0f - SLOT_GAP - (count - index) * step + SLOT_GAP, BAR_Y}; - return {std::round(position.x), std::round(position.y), SLOT_SIZE, SLOT_SIZE}; + 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; } // Lays the panel out: the header across the top, and below it, right to left, the description, divider, scroll bar diff --git a/src/menu.h b/src/menu.h index ad87f02..05ced7d 100644 --- a/src/menu.h +++ b/src/menu.h @@ -74,6 +74,7 @@ public: void target_fill(Game& game); bool is_bars_open(); Rect slot_rect(bool isTrinket, int index, int count); + IconSize slot_size_get(int count); bool is_choosable(Game& game, bool isTrinket); int target_get(Game& game, bool isTrinket); diff --git a/src/renderer.cpp b/src/renderer.cpp index e5931a9..4fe7505 100644 --- a/src/renderer.cpp +++ b/src/renderer.cpp @@ -238,6 +238,11 @@ bool Renderer::init(SDL_Window* target) gl.GenFramebuffers(1, &canvasFramebuffer); gl.BindFramebuffer(GL_FRAMEBUFFER, canvasFramebuffer); gl.FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, canvas, 0); + layer = texture_create(CANVAS_WIDTH, CANVAS_HEIGHT, nullptr); + gl.GenFramebuffers(1, &layerFramebuffer); + gl.BindFramebuffer(GL_FRAMEBUFFER, layerFramebuffer); + gl.FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, layer, 0); + framebuffer = canvasFramebuffer; gl.Viewport(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); gl.GenRenderbuffers(1, &upscaled); gl.GenFramebuffers(1, &upscaledFramebuffer); @@ -410,11 +415,12 @@ void Renderer::upscaled_resize(int factor) upscale = factor; } -// Draws the batch so far onto the canvas (clearing it first, the first time in a frame) and empties it. +// Draws the batch so far onto the canvas or the layer (clearing the canvas first, the first time in a frame) and +// empties it. void Renderer::flush() { - gl.BindFramebuffer(GL_FRAMEBUFFER, canvasFramebuffer); - if (!isCanvasCleared) + gl.BindFramebuffer(GL_FRAMEBUFFER, framebuffer); + if (framebuffer == canvasFramebuffer && !isCanvasCleared) { gl.ClearColor(BACKGROUND.r, BACKGROUND.g, BACKGROUND.b, BACKGROUND.a); gl.Clear(GL_COLOR_BUFFER_BIT); @@ -425,6 +431,36 @@ void Renderer::flush() vertices.clear(); } +// Sends what's drawn from here to layer end onto a clear layer of its own instead of the canvas. +void Renderer::layer_begin() +{ + flush(); + framebuffer = layerFramebuffer; + gl.BindFramebuffer(GL_FRAMEBUFFER, layerFramebuffer); + gl.ClearColor(0, 0, 0, 0); + gl.Clear(GL_COLOR_BUFFER_BIT); +} + +// Lays the layer over the canvas at alpha, as one piece, so nothing in it shows through anything else in it. The layer +// holds its colours already multiplied by their alpha (blending onto clear), so it's laid with its colour taken whole. +void Renderer::layer_end(float alpha) +{ + flush(); + framebuffer = canvasFramebuffer; + uint32_t color = color_pack({alpha, alpha, alpha, alpha}); + Vertex corners[QUAD_CORNERS] = {{0, 0, 0, 1, color, 0}, + {CANVAS_WIDTH, 0, 1, 1, color, 0}, + {CANVAS_WIDTH, CANVAS_HEIGHT, 1, 0, color, 0}, + {0, CANVAS_HEIGHT, 0, 0, color, 0}}; + for (int index : QUAD_ORDER) + vertices.push_back(corners[index]); + gl.BindTexture(GL_TEXTURE_2D, layer); + gl.BlendFuncSeparate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + flush(); + gl.BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + gl.BindTexture(GL_TEXTURE_2D, atlas); +} + // Draws the rest of the frame onto the canvas, blits it up to the upscaled buffer, then into the letterboxed window // (whose rows count from the bottom) and swaps. void Renderer::present() diff --git a/src/renderer.h b/src/renderer.h index f6ceb7d..65872d9 100644 --- a/src/renderer.h +++ b/src/renderer.h @@ -63,6 +63,8 @@ public: void line_draw(Vec2 from, Vec2 to, float width, Color color); void circle_draw(Vec2 center, float radius, Color color); void ring_draw(Vec2 center, float radius, Color color); + void layer_begin(); + void layer_end(float alpha); void present(); void vsync_set(bool isVsync); Rect letterbox_get(float width, float height); @@ -75,6 +77,9 @@ private: unsigned int atlas = 0; unsigned int canvas = 0; unsigned int canvasFramebuffer = 0; + unsigned int layer = 0; + unsigned int layerFramebuffer = 0; + unsigned int framebuffer = 0; unsigned int upscaled = 0; unsigned int upscaledFramebuffer = 0; int shelfHeight = 0; diff --git a/src/scene.cpp b/src/scene.cpp index e91bbe1..5eb73dd 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -26,13 +26,12 @@ constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"}; constexpr float POUND_RING_ALPHA = 0.25f; constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"}; -constexpr float ARROW_FADE = 0.6f; constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1}; constexpr float BEAM_INNER = 4; constexpr float BEAM_OUTER = 10; -constexpr float BOLT_JITTER = 4; +constexpr float BOLT_JITTER = 2; constexpr float BOLT_SEGMENT = 8; -constexpr float BOLT_WIDTH = 2; +constexpr float BOLT_WIDTH = 1; constexpr float BOOMERANG_SPIN = 20; constexpr float CHAIN_SPACING = 8; // How hard a charging weapon shakes against an attack filled in as far. @@ -238,29 +237,43 @@ static float kick_get(Fighter& fighter) return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK); } +// How much of a weapon's cooldown is left, from 1 just fired to 0 ready. +static float cooldown_left_get(Fighter& fighter, int key) +{ + float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); + return total > 0 ? std::clamp(fighter.cooldowns[key] / total, 0.0f, 1.0f) : 0; +} + // A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips // down and back like a pump, then swings back to the aim just as it's ready. -static void reload_pose(Fighter& fighter, int key, float cooldown, Draw& draw) +static void reload_pose(Fighter& fighter, int key, Draw& draw) { - if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0) + if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || fighter.cooldowns[key] <= 0) return; - float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); - float t = std::clamp((1 - cooldown / total - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f); + float t = std::clamp((1 - cooldown_left_get(fighter, key) - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f); float weight = math_sin(PI * t); draw.angle = angle_lerp(draw.angle, -PI / 2, weight); draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP); } -// How far a fighter's weapons fill in with its colour: with the charge as it builds, or the speed bonus if that's more, -// then once the charge is ready blinking between full and the speed bonus alone. -static float charge_fill_get(Game& game, Fighter& fighter) +// How far a fighter's weapons (and crosshair) fill in with its colour: with the charge as it builds, or the speed bonus +// if that's more, then once the charge is ready blinking between full and the speed bonus alone. +float Scene::charge_fill_get(Fighter& fighter) { float buff = game.sim.weapons.buff_get(fighter); if (fighter.charge >= 1) - return game.scene.is_flashing(fighter) ? 1.0f : buff; + return is_flashing(fighter) ? 1.0f : buff; return std::max(buff, fighter.charge); } +// How far a fighter's weapon (by key, or -1 for none) and crosshair shake this frame: harder the further its charge has +// got, and from fully charged as the weapon is used, easing to still as its cooldown runs out. +Vec2 Scene::weapon_shake_get(Fighter& fighter, int key) +{ + float left = key < 0 ? 0 : cooldown_left_get(fighter, key); + return jitter_get(std::max(fighter.charge, left * left) * CHARGE_SHAKE); +} + static bool is_pair(const char* const (&pair)[2], const char* animation) { return animation == pair[0] || animation == pair[1]; @@ -313,6 +326,12 @@ void Scene::flash_spawn(Vec2 position, float angle, float size, int owner) game.effects.flash_spawn(position, angle, size, game.color_get(owner)); } +void Scene::ring_spawn(Vec2 position, float radius, int owner) +{ + if (!isMuted) + game.effects.ring_spawn(position, radius, game.color_get(owner)); +} + void Scene::fighter_afterimage_spawn(Fighter& fighter) { if (!isMuted) @@ -674,7 +693,7 @@ void Scene::attacks_draw() if (kind == ATTACK_HOOK) { // The rope runs from the end of the grapple's base in the hand. - Vec2 hand = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + Vec2 hand = fighter.hand_get(attack.hand); Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE); strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER}); } @@ -890,7 +909,7 @@ Pose Scene::attack_pose(Attack& attack) // their path, each a little out of step with the rest. bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0; draw.scale = {attack.scale, attack.scale}; - draw.angle = isFlying ? attack.angle : PI / 2; + draw.angle = isFlying ? attack.angle - PI / 2 : 0; pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps}; break; } @@ -905,21 +924,21 @@ Pose Scene::attack_pose(Attack& attack) } // Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it -// charges (see charge_fill_get) and shakes harder the further the charge has got. A charging mine weapon holds up the -// mine it's about to place instead. +// charges (see charge_fill_get) and shakes with its charge and cooldown (see weapon_shake_get). A charging mine weapon +// holds up the mine it's about to place instead. void Scene::held_draw(Fighter& fighter) { int held = fighter.weapon_key(fighter.slot); - if (held < 0 || (game.sim.weapons.is_busy(fighter, held) && !game.sim.weapons.tether_get(fighter, held, false))) + if (held < 0 || (game.sim.weapons.is_busy(fighter, held) && !game.sim.weapons.tether_get(fighter, held, 0))) return; float side = fighter.is_left() ? -1 : 1; Draw draw = { .color = game.color_get(fighter.index), - .position = fighter.hand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), + .position = fighter.hand_get() + weapon_shake_get(fighter, held), .scale = {1, side}, .angle = vec2_angle(fighter.aim) + kick_get(fighter) * side, - .flash = charge_fill_get(game, fighter), + .flash = charge_fill_get(fighter), }; if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE) { @@ -928,36 +947,38 @@ void Scene::held_draw(Fighter& fighter) game.sprites.effects.draw(game.renderer, "Mine", 0, draw); return; } - reload_pose(fighter, held, fighter.cooldowns[held], draw); - hand_draw(fighter, held, false, draw); + reload_pose(fighter, held, draw); + hand_draw(fighter, held, 0, draw); } -// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it. +// 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) { - int off = fighter.off_slot_get(); - off = off < 0 ? -1 : fighter.weapon_key(off); - if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 || - (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, true))) - return; - + int offs[SLOT_MAX]; float side = fighter.is_left() ? -1 : 1; - Draw draw = { - .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), - .position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), - .scale = {1, side}, - .angle = vec2_angle(fighter.aim), - .flash = charge_fill_get(game, fighter), - }; - reload_pose(fighter, off, fighter.cooldowns[off], draw); - hand_draw(fighter, off, true, draw); + for (int hand = fighter.off_slots_get(offs); hand >= 1; hand--) + { + int off = fighter.weapon_key(offs[hand - 1]); + if (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, hand)) + continue; + Draw draw = { + .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), + .position = fighter.hand_get(hand) + weapon_shake_get(fighter, off), + .scale = {1, side}, + .angle = vec2_angle(fighter.aim), + .flash = charge_fill_get(fighter), + }; + reload_pose(fighter, off, draw); + hand_draw(fighter, off, hand, draw); + } } // Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base (" Base"), pointing // down the lash or rope. -void Scene::hand_draw(Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw) +void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw) { - Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, isOffhand); + Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand); std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name; if (tether) { diff --git a/src/scene.h b/src/scene.h index 9d5af2b..10ed19c 100644 --- a/src/scene.h +++ b/src/scene.h @@ -17,6 +17,8 @@ public: void update(); void draw(); bool is_flashing(Fighter& fighter); + float charge_fill_get(Fighter& fighter); + Vec2 weapon_shake_get(Fighter& fighter, int key); void sound_play(SoundId sound) override; void charge_play(Fighter& fighter) override; @@ -25,6 +27,7 @@ public: void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) override; void shake_add(float amount) override; void flash_spawn(Vec2 position, float angle, float size, int owner) override; + void ring_spawn(Vec2 position, float radius, int owner) override; void fighter_afterimage_spawn(Fighter& fighter) override; void attack_afterimage_spawn(Attack& attack) override; void fighter_aura_spawn(Fighter& fighter) override; @@ -55,5 +58,5 @@ private: Pose attack_pose(Attack& attack); void held_draw(Fighter& fighter); void offhand_draw(Fighter& fighter); - void hand_draw(Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw); + void hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw); }; diff --git a/src/settings.h b/src/settings.h index 8b0276a..76bbba7 100644 --- a/src/settings.h +++ b/src/settings.h @@ -42,11 +42,11 @@ public: int weaponSlots = 2; bool isAimShown = true; bool isCollisionShown = false; - bool isDpsShown = true; + bool isDpsShown = false; bool isFullscreen = false; bool isMuted = false; bool isShaking = true; - bool isSpeedShown = 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] = {}; diff --git a/src/sim.h b/src/sim.h index a8c2ff2..37abb29 100644 --- a/src/sim.h +++ b/src/sim.h @@ -23,6 +23,8 @@ public: {} virtual void flash_spawn(Vec2 position, float angle, float size, int owner) {} + virtual void ring_spawn(Vec2 position, float radius, int owner) + {} virtual void fighter_afterimage_spawn(Fighter& fighter) {} virtual void attack_afterimage_spawn(Attack& attack) diff --git a/src/sprites.cpp b/src/sprites.cpp index 8935188..f59b1bc 100644 --- a/src/sprites.cpp +++ b/src/sprites.cpp @@ -7,13 +7,9 @@ void Sprites::init(Game& game) dummy.load(renderer, "dummy"); effects.load(renderer, "effects"); icons.load(renderer, "weapon_icons"); - iconsSmall.load(renderer, "weapon_icons_small"); - iconsTiny.load(renderer, "weapon_icons_tiny"); player.load(renderer, "player"); tiles.load(renderer, "tiles"); trinkets.load(renderer, "trinkets"); - trinketsSmall.load(renderer, "trinkets_small"); - trinketsTiny.load(renderer, "trinkets_tiny"); modes.load(renderer, "modes"); ui.load(renderer, "ui"); weapons.load(renderer, "weapons"); diff --git a/src/sprites.h b/src/sprites.h index d02027c..a46b55d 100644 --- a/src/sprites.h +++ b/src/sprites.h @@ -12,15 +12,12 @@ class Sprites public: Anm2 dummy; Anm2 effects; - // Weapon and trinket icons at full (24 pixel), small (16) and tiny (8) sizes. + // Weapon and trinket icons at full (24 pixel), small (16) and tiny (8) sizes, the smaller ones named with Small or + // Tiny after. Anm2 icons; - Anm2 iconsSmall; - Anm2 iconsTiny; Anm2 player; Anm2 tiles; Anm2 trinkets; - Anm2 trinketsSmall; - Anm2 trinketsTiny; // A game mode's icon, by its name (Deathmatch, Race). Anm2 modes; Anm2 ui; diff --git a/src/state.cpp b/src/state.cpp index d656122..26df114 100644 --- a/src/state.cpp +++ b/src/state.cpp @@ -119,6 +119,7 @@ template static void state_walk(Stream& stream, Sim stream.list(sim.weapons.attacks); World& world = sim.world; stream.list(world.dummies); + stream.list(world.ledges); stream.list(world.obstacles); stream.list(world.ramps); stream.list(world.tiles); diff --git a/src/text.cpp b/src/text.cpp index df1bee9..1c79bfa 100644 --- a/src/text.cpp +++ b/src/text.cpp @@ -7,6 +7,43 @@ constexpr const char* FONT_FOLDER = "font/"; constexpr const char* FONT_NAMES[FONT_COUNT] = {"tiny", "small", "big", "huge"}; constexpr Vec2 OUTLINE_OFFSETS[] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; +constexpr int UTF8_PAIR_MASK = 0xE0; +constexpr int UTF8_PAIR_LEAD = 0xC0; +constexpr int UTF8_PAIR_BITS = 0x1F; +constexpr int UTF8_TRIPLE_MASK = 0xF0; +constexpr int UTF8_TRIPLE_LEAD = 0xE0; +constexpr int UTF8_TRIPLE_BITS = 0x0F; +constexpr int UTF8_TAIL_BITS = 0x3F; +constexpr int UTF8_TAIL_SHIFT = 6; +// Characters past Latin-1 the fonts have (the arrows), each kept in the glyph of a C1 control code from here up, as +// those are never printed. +constexpr int GLYPH_EXTRAS[] = {0x2190, 0x2191, 0x2192, 0x2193}; +constexpr int GLYPH_EXTRA_FIRST = 0x80; +constexpr int LATIN1_END = 0x100; + +// A UTF-8 continuation byte's bits added under code. +static int utf8_tail_add(int code, const char*& character) +{ + return code << UTF8_TAIL_SHIFT | ((unsigned char)*++character & UTF8_TAIL_BITS); +} + +// The glyph a string's next character is (a byte, or two or three bytes of UTF-8), leaving the pointer on its last +// byte, or -1 for none. +static int glyph_next(const char*& character) +{ + int code = (unsigned char)*character; + if ((code & UTF8_PAIR_MASK) == UTF8_PAIR_LEAD && character[1]) + code = utf8_tail_add(code & UTF8_PAIR_BITS, character); + else if ((code & UTF8_TRIPLE_MASK) == UTF8_TRIPLE_LEAD && character[1] && character[2]) + code = utf8_tail_add(utf8_tail_add(code & UTF8_TRIPLE_BITS, character), character); + if (code >= LATIN1_END) + { + int extra = std::find(std::begin(GLYPH_EXTRAS), std::end(GLYPH_EXTRAS), code) - std::begin(GLYPH_EXTRAS); + code = extra < (int)std::size(GLYPH_EXTRAS) ? GLYPH_EXTRA_FIRST + extra : -1; + } + int index = code - GLYPH_FIRST; + return code >= 0 && index >= 0 && index < GLYPH_COUNT ? index : -1; +} void Text::init(Renderer& renderer) { @@ -54,10 +91,9 @@ void Text::load(Renderer& renderer, Font font, const char* name) for (auto [key, value] : glyphs) { simdjson::dom::object fields; - if (key.size() != 1 || value.get(fields)) - continue; - int index = key[0] - GLYPH_FIRST; - if (index < 0 || index >= GLYPH_COUNT) + const char* character = key.data(); + int index = key.empty() ? -1 : glyph_next(character); + if (index < 0 || character != key.data() + key.size() - 1 || value.get(fields)) continue; atlas.glyphs[index] = { .region = {sheet.x + number_get(fields, "x", 0), sheet.y + number_get(fields, "y", 0), @@ -119,8 +155,8 @@ void Text::draw(Renderer& renderer, const char* string, Draw draw, Font font) continue; } - int index = *character - GLYPH_FIRST; - if (index < 0 || index >= GLYPH_COUNT) + int index = glyph_next(character); + if (index < 0) continue; Glyph& glyph = atlas.glyphs[index]; @@ -142,13 +178,13 @@ Vec2 Text::size_get(const char* string, Font font, float scale) for (const char* character = string; *character; character++) { - int index = *character - GLYPH_FIRST; + int index = *character == '\n' ? -1 : glyph_next(character); if (*character == '\n') { lineWidth = 0; size.y += atlas.lineHeight; } - else if (index >= 0 && index < GLYPH_COUNT) + else if (index >= 0) lineWidth += atlas.glyphs[index].advance; size.x = std::max(size.x, lineWidth); } diff --git a/src/text.h b/src/text.h index 88bcc23..9a79c54 100644 --- a/src/text.h +++ b/src/text.h @@ -2,8 +2,11 @@ #include "renderer.h" -constexpr int GLYPH_COUNT = 95; +// Glyphs cover Latin-1 from the space up (its upper half written as two-byte UTF-8, like the degree sign), plus the +// arrows (see text.cpp), though only ASCII is typed. +constexpr int GLYPH_COUNT = 224; constexpr int GLYPH_FIRST = 32; +constexpr int GLYPH_TYPED_END = 127; // The small font's line height (small.fnt's), which the HUD is laid out by. constexpr float LINE_HEIGHT = 12; diff --git a/src/trinket.h b/src/trinket.h index 7f597e6..24bde3f 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -71,8 +71,8 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { "A noble spirit embiggens the\nsmallest projectile."}, {"Diazepam", "Steadies your aim: every attack,\nvolley and Splitter copy spreads less.", - {"33% Less Spread", "66% Less Spread", "No Spread"}, - {0.33f, 0.66f, 1}, + {"17% Less Spread", "33% Less Spread", "50% Less Spread"}, + {1 / 6.0f, 1 / 3.0f, 0.5f}, "Diazepam?\nPsychotropic drug?\nStops hands from trembling?\nQuestions?", 1}, {"Splitter", @@ -86,7 +86,7 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { {0.1f, 0.25f, 1 / 3.0f}, "Stud Multiplier"}, {"Dual Wield", - "Your previous weapon fires from\nyour off hand too, for a cut of\nits damage.", + "Your last-held weapon fires from\nyour off hand too, for a cut of\nits damage. Each copy adds another\nweapon, the next most recent.", {"10% Off-Hand Damage", "25% Off-Hand Damage", "33% Off-Hand Damage"}, {0.1f, 0.25f, 1 / 3.0f}, "Guns akimbo!"}, @@ -99,20 +99,20 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { "RICOCHET 2 BABY!"}, {"Tesla Coil", "Lightning chains between your\nweapons out in the world, shocking\nanything it touches.", - {"4 Damage Shocks\n120 Arc Range", "7 Damage Shocks\n170 Arc Range", "10 Damage Shocks\n220 Arc Range"}, - {4, 7, 10}, + {"2 Damage Shocks\n120 Arc Range", "4 Damage Shocks\n170 Arc Range", "6 Damage Shocks\n220 Arc Range"}, + {2, 4, 6}, "Okay this is just Technology Zero."}, {"Recoil Coil", "Attacks push you away from your\naim, harder the more damage\nthey deal. Rapid fire adds up.", - {"12 Recoil Power", "18 Recoil Power", "24 Recoil Power"}, - {12, 18, 24}, + {"6 Recoil Power", "9 Recoil Power", "12 Recoil Power"}, + {6, 9, 12}, "The forgotten sibling of Gravity\nand Speed..."}, {"Shock Absorber", "Your own weapons move you around\nless (recoil, kickback, lunges,\nrebounds, pulls and blasts), and\nyou take " "less knockback.", - {"25% Less Weapon Movement\n17% Less Knockback Taken", "50% Less Weapon Movement\n33% Less Knockback Taken", - "75% Less Weapon Movement\n50% Less Knockback Taken"}, - {0.75f, 0.5f, 0.25f}, + {"12.5% Less Weapon Movement\n8% Less Knockback Taken", "25% Less Weapon Movement\n17% Less Knockback Taken", + "37.5% Less Weapon Movement\n25% Less Knockback Taken"}, + {0.875f, 0.75f, 0.625f}, "The mechanical kind, not the\nelectrical kind."}, {"Shrinker", "Makes you smaller.", @@ -147,8 +147,8 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { "Probably given to you from\nEnergy Drink."}, {"Feather", "Hold Up in the air to float.", - {"0.6x Gravity Holding Up", "0.45x Gravity Holding Up", "0.3x Gravity Holding Up"}, - {0.6f, 0.45f, 0.3f}, + {"0.75x Gravity Holding Up", "0.625x Gravity Holding Up", "0.5x Gravity Holding Up"}, + {0.75f, 0.625f, 0.5f}, "Cape not included."}, {"Iron Weights", "Fall even faster holding Down.\nLanding while holding Down slams\nthe ground, harder the faster you\nhit it.", diff --git a/src/ui.cpp b/src/ui.cpp index 240fd07..475e1ec 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3,7 +3,6 @@ #include #include -constexpr int CHAMFER = 2; constexpr float BANNER_SCALE = 4; constexpr float BANNER_Y = 110; constexpr float BAR_GAP = 2; @@ -19,11 +18,17 @@ constexpr float RIVAL_OUT_ALPHA = 0.25f; constexpr float SPECTATE_GAP = 4; constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK}; constexpr float HUD_IDLE_ALPHA = 0.5f; +constexpr float HUD_HOVER_ALPHA = 0.5f; constexpr float HUD_HOVER_FADE = 0.15f; constexpr float RIVAL_PADDING = 2; constexpr int RIVAL_COLUMNS_MAX = 3; -constexpr int RIVAL_FULL_SLOTS = 4; -constexpr int RIVALS_PER_ROW = 4; +// What the other fighters' windows try, from largest to smallest, until they all fit in a couple of rows: shrinking +// icons before the font, then leaving the icons out. +constexpr int RIVAL_ROWS_MAX = 2; +constexpr int RIVAL_STYLES = 6; +constexpr int RIVAL_ICON_STYLES = 4; +constexpr Font RIVAL_FONTS[RIVAL_STYLES] = {FONT_SMALL, FONT_SMALL, FONT_TINY, FONT_TINY, FONT_SMALL, FONT_TINY}; +constexpr IconSize RIVAL_SIZES[RIVAL_ICON_STYLES] = {ICON_FULL, ICON_SMALL, ICON_SMALL, ICON_TINY}; constexpr float COOLDOWN_SLOTS = 2; constexpr float MINI_ICON_SCALE = 0.5f; constexpr float MINI_SLOT = 16; @@ -36,8 +41,10 @@ constexpr float READY_GAP = 2; constexpr float TIP_GAP = 4; constexpr float COUNTDOWN_Y = 40; constexpr float COUNTDOWN_DIM = 0.6f; -constexpr float CHEVRON_INSET = 3; -constexpr float CHEVRON_STEP = 3; +constexpr const char* ICON_SUFFIXES[ICON_SIZE_COUNT] = {"", " Small", " Tiny"}; +constexpr const char* CHEVRONS[ICON_SIZE_COUNT] = {"Chevron", "Chevron Small", "Chevron Tiny"}; +constexpr float CHEVRON_INSETS[ICON_SIZE_COUNT] = {3, 2, 2}; +constexpr float CHEVRON_STEPS[ICON_SIZE_COUNT] = {3, 2, 2}; constexpr float CHARGE_BAR_HEIGHT = 8; constexpr float CHARGE_BAR_WIDTH = 20; constexpr float DPS_SCALE = 2; @@ -52,21 +59,20 @@ constexpr float SCORE_HOTBAR_GAP = 8; constexpr float STATUS_BAR_HEIGHT = 12; constexpr float STATUS_BAR_WIDTH = 80; constexpr float STATUS_GAP = 6; -constexpr float PANEL_BORDER = 2; +// How thick a slot's (and its cooldown bar's) edge is at each size. +constexpr float SLOT_STROKES[ICON_SIZE_COUNT] = {PANEL_BORDER, PANEL_BORDER, 1}; constexpr float HOVER_BORDER = 1; constexpr float HOVER_GAP = 1; constexpr float TARGET_GAP = 1; constexpr float TARGET_HEIGHT = 2; constexpr float TARGET_INSET = 4; constexpr float CARET_BLINK = 0.5f; -constexpr float COOLDOWN_BAR_HEIGHT = 7; +constexpr float COOLDOWN_BAR_HEIGHTS[ICON_SIZE_COUNT] = {7, 6, 6}; constexpr float COOLDOWN_FADE_IN = 0.1f; constexpr float COOLDOWN_FADE_OUT = 0.25f; constexpr float COOLDOWN_GAP = 4; -constexpr float COOLDOWN_SPACING = 26; -constexpr float COOLDOWN_COMPACT_SPACING = 14; +constexpr float COOLDOWN_ROW_GAP = 2; constexpr float COOLDOWN_SLIDE = 12; -constexpr int COOLDOWN_FULL_ROWS = 5; constexpr float COOLDOWN_VISIBLE = 0.25f; constexpr float OFFSCREEN_ARROW_SCALE = 2; constexpr float OFFSCREEN_MARGIN = 8; @@ -89,7 +95,7 @@ constexpr float PREVIEW_CAPTION_GAP = 8; constexpr float PREVIEW_FEET = 120; constexpr float PREVIEW_NAME_Y = 16; constexpr float PREVIEW_SCALE = 3; -constexpr const char PREVIEW_ANIMATION[] = "Walk Right"; +constexpr const char PREVIEW_ANIMATION[] = "Preview"; constexpr float SUMMARY_ICON_GAP = 4; constexpr float NAME_PING_GAP = 3; // A queued game's area: its fill and edge, the queuer's icon over it (how high it floats and bobs, and how fast), and @@ -114,7 +120,12 @@ constexpr float TRACK_DOT = 6; constexpr float TRACK_HEIGHT = 2; constexpr float TRACK_WIDTH = 200; constexpr float SECONDS_PER_MINUTE = 60; -constexpr Vec2 LABEL_OFFSET = {4, 2}; +// Slot numbers: their font and corner at each size (none on tiny slots). +constexpr Font LABEL_FONTS[ICON_TINY] = {FONT_SMALL, FONT_TINY}; +constexpr Vec2 LABEL_OFFSETS[ICON_TINY] = {{4, 2}, {3, 2}}; +// Where a full slot's number sits from its icon's corner. +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}; constexpr const char* TIPS[] = { "Big hits launch fighters, and slamming into walls while launched hurts.", @@ -155,6 +166,12 @@ constexpr const char* TIPS[] = { static CountdownInfo countdown_info_get(Game& game, bool isPad); +// 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) +{ + return 1 - (1 - HUD_HOVER_ALPHA) * hover; +} + void Ui::init(Game& game) { for (int i = 0; i < MAX_FIGHTERS; i++) @@ -194,6 +211,7 @@ void Ui::draw(Game& game) // The menu covers the match overlays. Hotbars sit under them, except in the sandbox menu they slide into. With // several players on this screen in a match, each gets a compact panel along the bottom instead. + statusRect = {}; queues_draw(game); bars_draw(game); game.chat.overhead_draw(game); @@ -204,8 +222,8 @@ void Ui::draw(Game& game) bool isShared = game.sim.match.state != MATCH_PRACTICE && game.local_count() > 1; if (!isBarsRaised && !isShared && !isWatching) { - hotbar_draw(game); cooldowns_draw(game); + hotbar_draw(game); } if (isShared) panels_draw(game); @@ -283,12 +301,11 @@ void Ui::bars_draw(Game& game) } } -// A bordered bar filled from the left by fill, from 0 to 1. -void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color) +// A bar, its edge stroke thick, filled from the left by fill, from 0 to 1. +void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float stroke) { - panel_draw(renderer, bar, color); - renderer.rect_draw({bar.x + PANEL_BORDER, bar.y + PANEL_BORDER, std::round((bar.w - PANEL_BORDER * 2) * fill), - bar.h - PANEL_BORDER * 2}, + panel_draw(renderer, bar, color, -1, stroke); + renderer.rect_draw({bar.x + stroke, bar.y + stroke, std::round((bar.w - stroke * 2) * fill), bar.h - stroke * 2}, color); } @@ -329,7 +346,9 @@ void Ui::match_draw(Game& game) length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]); Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; - float hotbarRight = HOTBAR_MARGIN + game.local_get().weaponSlots * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP; + int weapons = game.local_get().weaponSlots; + Rect last = game.menu.slot_rect(false, 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; for (int i = 0; i < game.sim.fighterCount; i++) @@ -598,6 +617,7 @@ LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) continue; int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i]; Tooltip& tooltip = layout.tooltips[kind][i] = tooltip_build(game, isTrinket, item, level); + tooltip.slot = i; tooltip.flavor[0] = '\0'; tooltip.rowCount = 0; if (isTrinket && !isBrief) @@ -702,10 +722,10 @@ void Ui::loadout_draw(Game& game, float alpha, float top) tooltip_text_draw(game, layout.tooltips[kind][i], {.color = color, .position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}, font); - icon_draw(game, isTrinket, item, - {.color = color, - .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, - panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); + tooltip_icon_draw(game, layout.tooltips[kind][i], + {.color = color, + .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, + panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); } } } @@ -797,8 +817,10 @@ void Ui::status_draw(Game& game) { char text[TOOLTIP_LENGTH]; float right = CANVAS_WIDTH - HOTBAR_MARGIN; + float left = right; float y = HOTBAR_MARGIN; float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE); + game.renderer.layer_begin(); if (game.sim.match.state != MATCH_PRACTICE && !game.sim.match.is_racing()) { @@ -809,17 +831,16 @@ void Ui::status_draw(Game& game) bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint); SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); float width = game.text.size_get(text, FONT_SMALL, DPS_SCALE).x; - game.text.draw( - game.renderer, text, - {.color = tint, .position = {std::round(bar.x - STATUS_GAP - width), y}, .scale = {DPS_SCALE, DPS_SCALE}}); + left = std::round(bar.x - STATUS_GAP - width); + game.text.draw(game.renderer, text, {.color = tint, .position = {left, y}, .scale = {DPS_SCALE, DPS_SCALE}}); y += rowHeight; } // The DPS and speed meters each show only if they're on in the settings. auto meter_draw = [&](const char* label) { - float width = game.text.size_get(label, FONT_SMALL, DPS_SCALE).x; - game.text.draw(game.renderer, label, - {.color = tint, .position = {std::round(right - width), y}, .scale = {DPS_SCALE, DPS_SCALE}}); + float x = std::round(right - game.text.size_get(label, FONT_SMALL, DPS_SCALE).x); + left = std::min(left, x); + game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}, .scale = {DPS_SCALE, DPS_SCALE}}); y += rowHeight; }; if (game.settings.isDpsShown) @@ -832,10 +853,12 @@ void Ui::status_draw(Game& game) SDL_snprintf(text, sizeof(text), "%d u/s", (int)vec2_length(game.local_get().body.velocity)); meter_draw(text); } + statusRect = {left, HOTBAR_MARGIN, right - left, y - HOTBAR_MARGIN}; + game.renderer.layer_end(hover_alpha_get(statusHover)); } // The weapon bar (lit on the selected slot) and the trinket bar (lit where filled). The menu decides where -// they sit. Hovering a filled slot outside the menu shows its tooltip. +// they sit, and how large. Hovering a filled slot outside the menu shows its tooltip. void Ui::hotbar_draw(Game& game) { Vec2 mouse = game.input.mouse; @@ -848,21 +871,22 @@ void Ui::hotbar_draw(Game& game) int index = isTrinket ? i - fighter.weaponSlots : i; int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; int item = isTrinket ? fighter.trinketLoadout[index] : fighter.weaponLoadout[index]; - // In the HUD, slots are dimmed until the mouse comes over them; under the menu they're at full brightness. - float hover = slotHovers[isTrinket][index]; Slot slot = { .rect = game.menu.slot_rect(isTrinket, index, count), - .alpha = game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover, + .size = game.menu.slot_size_get(count), .item = item, .level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[index], .label = index + 1, .isHoverable = true, .isLit = isTrinket ? item >= 0 : index == fighter.slot, .isTrinket = isTrinket, - .isOpaque = true, .isTarget = game.menu.target_get(game, isTrinket) == index, }; + // In the HUD, slots are see-through until the mouse comes over them. Under the menu they're solid. + float hover = slotHovers[isTrinket][index]; + game.renderer.layer_begin(); slot_draw(game, slot); + game.renderer.layer_end(game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover); if (item >= 0 && is_point_in_rect(mouse, slot.rect)) hovered = slot; } @@ -870,48 +894,51 @@ void Ui::hotbar_draw(Game& game) if (hovered.item < 0 || game.menu.isOpen) return; Tooltip tooltip = tooltip_build(game, hovered.isTrinket, hovered.item, hovered.level); + tooltip.slot = hovered.label - 1; tooltip_draw(game, tooltip); } -// A bordered box with the item's icon, a stack of chevrons in the corner for its level, and an optional number. +// A bordered box with the item's icon, a stack of chevrons in the corner for its level, and an optional number (left +// off tiny boxes). Only full-size boxes have room for the hover border. void Ui::slot_draw(Game& game, Slot slot) { Renderer& renderer = game.renderer; bool isHovered = slot.isHoverable && !isFocusShown && is_point_in_rect(game.input.mouse, slot.rect); - Color base = slot.isLit || isHovered || slot.isTarget ? tint : shade; - Color color = slot.isOpaque ? color_shade(base, slot.alpha) : color_fade(base, slot.alpha); - float fill = slot.isOpaque ? 1 : slot.alpha; - panel_draw(renderer, slot.rect, color, fill); + Color color = color_fade(slot.isLit || isHovered || slot.isTarget ? tint : shade, slot.alpha); + panel_draw(renderer, slot.rect, color, -1, SLOT_STROKES[slot.size]); if (slot.isTarget) renderer.rect_draw({slot.rect.x + TARGET_INSET, slot.rect.y + slot.rect.h + TARGET_GAP, slot.rect.w - TARGET_INSET * 2, TARGET_HEIGHT}, color); - if (isHovered) + if (isHovered && slot.size == ICON_FULL) { Rect rect = slot.rect; float inset = PANEL_BORDER + HOVER_GAP; chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, color); inset += HOVER_BORDER; chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, - color_fade(BLACK, fill)); + color_fade(BLACK, slot.alpha)); } if (slot.item >= 0) { icon_draw(game, slot.isTrinket, slot.item, - {.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}}); + {.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}}, + slot.size); - Vec2 corner = {slot.rect.x + slot.rect.w - CHEVRON_INSET, slot.rect.y + slot.rect.h - CHEVRON_INSET}; + float inset = CHEVRON_INSETS[slot.size]; + Vec2 corner = {slot.rect.x + slot.rect.w - inset, slot.rect.y + slot.rect.h - inset}; for (int i = 0; i <= slot.level; i++) - game.sprites.ui.draw(renderer, "Chevron", 0, - {.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEP}}); + game.sprites.ui.draw(renderer, CHEVRONS[slot.size], 0, + {.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEPS[slot.size]}}); } - if (slot.label <= 0) + if (slot.label <= 0 || slot.size == ICON_TINY) return; char label[] = {char('0' + slot.label), 0}; - game.text.draw(renderer, label, - {.color = color, .position = {slot.rect.x + LABEL_OFFSET.x, slot.rect.y + LABEL_OFFSET.y}}); + Vec2 offset = LABEL_OFFSETS[slot.size]; + game.text.draw(renderer, label, {.color = color, .position = {slot.rect.x + offset.x, slot.rect.y + offset.y}}, + LABEL_FONTS[slot.size]); } // A number for a stats table: whole when it's whole, otherwise to two decimal places at most. @@ -920,6 +947,9 @@ static void number_format(char* text, int size, float value) SDL_snprintf(text, size, "%g", std::round(value * 100) / 100); } +// The stats table's last row, how far a weapon pushes its fighter. +constexpr int LUNGE_ROW = 8; + // What a summary row shows for an attack, or "" where it doesn't apply. static void stat_format(char* text, int size, int row, const AttackSummary& summary, const AttackDef& attack, bool isCharged) @@ -976,36 +1006,42 @@ static void stat_format(char* text, int size, int row, const AttackSummary& summ if (summary.spread > 0) { number_format(number, sizeof(number), summary.spread); - SDL_snprintf(text, size, "%s deg", number); + SDL_snprintf(text, size, "%s°", number); } break; - case 8: + case LUNGE_ROW: if (summary.lunge != 0) - SDL_snprintf(text, size, summary.lunge > 0 ? "%g forward" : "%g back", - std::round(std::abs(summary.lunge))); + SDL_snprintf(text, size, "%g", std::round(std::abs(summary.lunge))); break; } } // Fills a weapon tooltip's stats table: a row per stat the weapon has (fired, damage, how often, range, blast, speed, // how many out at once, spread, lunge), always in that order, for its attack and its charged attack as this fighter -// would fire them. Values its trinkets change are marked; with a preview, ones the next level changes add "> next". +// would fire them. A weapon that only pushes its fighter back calls its lunge Recoil. Values its trinkets change are +// marked; with a preview, ones the next level changes add "→ next". void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip) { constexpr const char* LABELS[] = {"Fires", "Damage", "Every", "Range", "Blast", "Speed", "Out at once", "Spread", "Lunge"}; const WeaponDef& def = WEAPON_DEFS[weapon]; + AttackSummary summaries[2] = {attack_summary_get(fighter, weapon, level, false), + attack_summary_get(fighter, weapon, level, true)}; + AttackSummary nexts[2] = {}; + for (int column = 0; column < 2 && isPreview; column++) + nexts[column] = attack_summary_get(fighter, weapon, level + 1, column == 1); + bool isRecoil = summaries[0].lunge <= 0 && summaries[1].lunge <= 0; tooltip.rowCount = 0; for (int row = 0; row < (int)std::size(LABELS); row++) { StatRow& stat = tooltip.rows[tooltip.rowCount]; - stat = {.label = LABELS[row]}; + stat = {.label = row == LUNGE_ROW && isRecoil ? "Recoil" : LABELS[row]}; bool isShown = false; for (int column = 0; column < 2; column++) { bool isCharged = column == 1; const AttackDef& attack = isCharged ? def.charged : def.normal; - AttackSummary summary = attack_summary_get(fighter, weapon, level, isCharged); + AttackSummary& summary = summaries[column]; char* value = stat.values[column]; stat_format(value, STAT_LENGTH, row, summary, attack, isCharged); bool changes[] = {summary.isPiecesChanged, @@ -1021,10 +1057,9 @@ void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPrevi if (isPreview && *value) { char next[STAT_LENGTH]; - stat_format(next, sizeof(next), row, attack_summary_get(fighter, weapon, level + 1, isCharged), attack, - isCharged); + stat_format(next, sizeof(next), row, nexts[column], attack, isCharged); if (SDL_strcmp(next, value) != 0) - SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " > %s", next); + SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " → %s", next); } isShown |= *value != '\0'; } @@ -1074,7 +1109,7 @@ void Ui::table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, cons font); } -// A trinket's effects at one level with the next level's after a ">", line by line. Where a line only changes in its +// A trinket's effects at one level with the next level's after an arrow, line by line. Where a line only changes in its // numbers, just those show both values ("1.15x > 1.3x Attack Rate"); otherwise the whole line does ("Wall Cling > Wall // Cling, Wall Kick"), split onto a line of its own after the first when that's too wide. static void effects_preview_get(Game& game, const char* now, const char* next, char* text, int size) @@ -1126,12 +1161,12 @@ static void effects_preview_get(Game& game, const char* now, const char* next, c else if (isNumbers) for (size_t w = 0; w < wordsBefore.size(); w++) line += (w > 0 ? " " : "") + - (wordsBefore[w] == wordsAfter[w] ? wordsBefore[w] : wordsBefore[w] + " > " + wordsAfter[w]); + (wordsBefore[w] == wordsAfter[w] ? wordsBefore[w] : wordsBefore[w] + " → " + wordsAfter[w]); else if (before.empty()) - line = "> " + after; + line = "→ " + after; else { - line = before + " > " + after; + line = before + " → " + after; if (game.text.size_get(line.c_str()).x > PREVIEW_LINE_MAX) line = before + "\n> " + after; } @@ -1148,6 +1183,11 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o tooltip.isTrinket = isTrinket; tooltip.item = item; Fighter& fighter = owner >= 0 ? game.sim.fighters[owner] : game.local_get(); + int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; + int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; + tooltip.slot = std::find(loadout, loadout + slots, item) - loadout; + if (tooltip.slot >= slots) + tooltip.slot = -1; if (isTrinket) { @@ -1159,7 +1199,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o else SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); tooltip.name = def.name; - // In the sandbox, the effects show what the next level changes after a ">", as weapons' stats do. + // In the sandbox, the effects show what the next level changes after an arrow, as weapons' stats do. bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; char effects[TOOLTIP_LENGTH]; if (isPreview) @@ -1167,7 +1207,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o else SDL_strlcpy(effects, def.effects[level], sizeof(effects)); if (isPreview) - SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d, > Level %d)\n%s\n%s", level + 1, level + 2, + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d → Level %d)\n%s\n%s", level + 1, level + 2, effects, equipped); else SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s\n%s", level + 1, effects, equipped); @@ -1179,9 +1219,9 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o const WeaponDef& def = WEAPON_DEFS[item]; tooltip.name = def.name; - // In the sandbox, values that change at the next level show it after a ">". + // In the sandbox, values that change at the next level show it after an arrow. bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; - SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d, > Level %d)" : "(Level %d)", level + 1, + SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d → Level %d)" : "(Level %d)", level + 1, level + 2); stats_table_build(fighter, item, level, isPreview, tooltip); SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s\n\n%s", def.blurb, def.chargeBlurb); @@ -1237,10 +1277,11 @@ void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip) page_begin(game, page, area); float iconY = area.y - page.scroll; if (tooltip.item >= 0 && iconY >= area.y) - icon_draw(game, tooltip.isTrinket, tooltip.item, - {.color = tint, - .position = {area.x + area.w - (isScrolling ? SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, - iconY + ICON_SIZE / 2}}); + tooltip_icon_draw( + game, tooltip, + {.color = tint, + .position = {area.x + area.w - (isScrolling ? SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, + iconY + ICON_SIZE / 2}}); auto lines = [&](const char* text, Color color) { for (const char* start = text;;) { @@ -1293,32 +1334,43 @@ void Ui::tooltip_draw(Game& game, Tooltip& tooltip) panel_draw(game.renderer, panel, tint); tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}); if (tooltip.item >= 0) - icon_draw(game, tooltip.isTrinket, tooltip.item, - {.color = tint, - .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, - panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); + tooltip_icon_draw(game, tooltip, + {.color = tint, + .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, + panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); } -// Black box with a bordered, cut-cornered edge in the given color. The fill fades with the border unless given an -// alpha of its own. -void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha) +// A tooltip's item icon, centred on the placement, with the hotbar slot it's in numbered over its top left corner as on +// the hotbar. +void Ui::tooltip_icon_draw(Game& game, Tooltip& tooltip, Draw placement) { - chamfer_draw(renderer, rect, border); - chamfer_draw(renderer, - {rect.x + PANEL_BORDER, rect.y + PANEL_BORDER, rect.w - PANEL_BORDER * 2, rect.h - PANEL_BORDER * 2}, - color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a)); + icon_draw(game, tooltip.isTrinket, tooltip.item, placement); + if (tooltip.slot < 0) + return; + char label[] = {char('1' + tooltip.slot), 0}; + Vec2 corner = placement.position - Vec2{ICON_SIZE / 2, ICON_SIZE / 2} + ICON_LABEL_OFFSET; + game.text.draw(game.renderer, label, {.color = placement.color, .position = corner}); } -// Fills a rect with its corners cut diagonally, one pixel per step. -void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color) +// Black box with a cut-cornered edge stroke thick in the given color (its corners cut as deep). The fill fades with +// the border unless given an alpha of its own. +void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha, float stroke) { - for (int step = 0; step < CHAMFER; step++) + chamfer_draw(renderer, rect, border, stroke); + chamfer_draw(renderer, {rect.x + stroke, rect.y + stroke, rect.w - stroke * 2, rect.h - stroke * 2}, + color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a), stroke); +} + +// Fills a rect with its corners cut diagonally, cut pixels deep, one pixel per step. +void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color, float cut) +{ + for (int step = 0; step < cut; step++) { - float inset = CHAMFER - step; + float inset = cut - step; renderer.rect_draw({rect.x + inset, rect.y + step, rect.w - inset * 2, 1}, color); renderer.rect_draw({rect.x + inset, rect.y + rect.h - 1 - step, rect.w - inset * 2, 1}, color); } - renderer.rect_draw({rect.x, rect.y + CHAMFER, rect.w, rect.h - CHAMFER * 2}, color); + renderer.rect_draw({rect.x, rect.y + cut, rect.w, rect.h - cut * 2}, color); } void Ui::widgets_begin(float alpha, bool isLive) @@ -1496,7 +1548,7 @@ bool Ui::field_draw(Game& game, Rect box, char* text, int size) text[--length] = '\0'; for (const char* character = input.typed; *character && length < size - 1; character++) { - if (*character < GLYPH_FIRST || *character >= GLYPH_FIRST + GLYPH_COUNT) + if ((unsigned char)*character < GLYPH_FIRST || (unsigned char)*character >= GLYPH_TYPED_END) continue; text[length++] = *character; text[length] = '\0'; @@ -1676,8 +1728,8 @@ void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color col game.text.draw(game.renderer, name, {.color = color, .position = {std::round(centre - nameSize.x / 2), area.y + PREVIEW_NAME_Y}}); Anm2& player = game.sprites.player; - Anm2Animation* walk = player.animation_get(PREVIEW_ANIMATION); - float frame = walk ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)walk->length) : 0; + Anm2Animation* preview = player.animation_get(PREVIEW_ANIMATION); + float frame = preview ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)preview->length) : 0; player.draw(game.renderer, PREVIEW_ANIMATION, frame, {.color = color, .position = {centre, feet}, .scale = {PREVIEW_SCALE, PREVIEW_SCALE}}); if (!caption || !*caption) @@ -1692,6 +1744,11 @@ void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color col constexpr float PING_BAR_WIDTH = 2; constexpr float PING_BAR_GAP = 1; constexpr int PING_BARS = 4; +constexpr float PING_BARS_WIDTH = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP; +constexpr float PING_BAR_STEP = 2; +constexpr float PING_BARS_HEIGHT = PING_BARS * PING_BAR_STEP; +constexpr float PING_BARS_DROP = 3; +constexpr float PING_UNLIT_SHADE = 0.5f; constexpr float PING_LIMITS[PING_BARS - 1] = {60, 120, 200}; constexpr Color PING_COLORS[PING_BARS] = { {0.9f, 0.25f, 0.2f, 1}, {0.95f, 0.6f, 0.2f, 1}, {0.9f, 0.85f, 0.25f, 1}, {0.35f, 0.85f, 0.35f, 1}}; @@ -1704,37 +1761,48 @@ static std::string ping_text_get(float ping) float Ui::ping_width_get(Game& game, float ping) { std::string text = ping_text_get(ping); - float bars = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP; - return bars + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x); + return PING_BARS_WIDTH + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x); +} + +// How many of a connection's bars are lit (none while it's unknown). +static int ping_lit_get(float ping) +{ + if (ping < 0) + return 0; + int lit = PING_BARS; + for (float limit : PING_LIMITS) + lit -= ping >= limit; + return std::max(lit, 1); } float Ui::ping_draw(Game& game, Vec2 corner, float ping, float alpha) { - int lit = 0; - if (ping >= 0) - { - lit = PING_BARS; - for (float limit : PING_LIMITS) - lit -= ping >= limit; - lit = std::max(lit, 1); - } - Color color = lit > 0 ? PING_COLORS[lit - 1] : shade; - float x = std::round(corner.x); - float bottom = std::round(corner.y + LINE_HEIGHT - 3); - for (int i = 0; i < PING_BARS; i++) - { - float height = 2 + i * 2; - game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height}, - color_fade(i < lit ? color : color_shade(shade, 0.5f), alpha)); - x += PING_BAR_WIDTH + PING_BAR_GAP; - } + ping_bars_draw(game, {corner.x, corner.y + LINE_HEIGHT - PING_BARS_DROP}, ping, alpha); + int lit = ping_lit_get(ping); std::string text = ping_text_get(ping); if (!text.empty()) game.text.draw(game.renderer, text.c_str(), - {.color = color_fade(color, alpha), .position = {x + PING_BAR_GAP, corner.y}}); + {.color = color_fade(PING_COLORS[lit - 1], alpha), + .position = {std::round(corner.x) + PING_BARS_WIDTH + PING_BAR_GAP * 2, corner.y}}); return ping_width_get(game, ping); } +// A connection's signal bars alone, standing on corner (their bottom left). +void Ui::ping_bars_draw(Game& game, Vec2 corner, float ping, float alpha) +{ + int lit = ping_lit_get(ping); + Color color = lit > 0 ? PING_COLORS[lit - 1] : shade; + float x = std::round(corner.x); + float bottom = std::round(corner.y); + for (int i = 0; i < PING_BARS; i++) + { + float height = PING_BAR_STEP * (i + 1); + game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height}, + color_fade(i < lit ? color : color_shade(shade, PING_UNLIT_SHADE), alpha)); + x += PING_BAR_WIDTH + PING_BAR_GAP; + } +} + // What a game's rules make for, in a few lines under a title with its mode's icon: what it's about, how many play, the // slots, how items are handed out, and how it's won. The "game tooltip". static std::vector game_summary_lines(const Rules& rules) @@ -1887,8 +1955,7 @@ void Ui::update(Game& game) hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE); spectate_update(game); - // The HUD's hotbar slots and the other fighters' windows brighten while the mouse is over them, and dim again - // after. + // The HUD's elements change while the mouse is over them, and change back after. Vec2 mouse = game.input.mouse; auto hover_step = [&](float& hover, bool isHovered) { hover = std::clamp(hover + (isHovered ? 1 : -1) * TICK / HUD_HOVER_FADE, 0.0f, 1.0f); @@ -1907,6 +1974,13 @@ void Ui::update(Game& game) int k = std::find(rivals.fighters, rivals.fighters + rivals.count, i) - rivals.fighters; hover_step(rivalHovers[i], k < rivals.count && is_point_in_rect(mouse, rivals.cards[k])); } + for (int slot = 0; slot < SLOT_MAX; slot++) + { + hover_step(weaponBars[slot].hover, weaponBars[slot].fade > 0 && is_point_in_rect(mouse, weaponBars[slot].rect)); + hover_step(trinketBars[slot].hover, + trinketBars[slot].fade > 0 && is_point_in_rect(mouse, trinketBars[slot].rect)); + } + hover_step(statusHover, is_point_in_rect(mouse, statusRect)); // Every slot cools on its own bar: weapons whose cooldown is long enough to notice (copies that don't stack share // the first one's), and trinkets with a cooldown of their own (one bar for all copies). A bar keeps its item's @@ -1938,60 +2012,50 @@ void Ui::update(Game& game) bars_arrange(trinketBars); } -// Every item cooling down shows at once, each on its own bar two slots long (whatever the hotbar's size) with its icon. -// Every bar cooling down, a row each: weapons downward from under the weapon hotbar, trinkets upward from over the -// trinket hotbar, each starting far enough off its hotbar that the icons (taller than the bars) clear its slots. Past a -// handful of one kind at once, its rows tighten and their icons shrink to half size so a full loadout still fits. +// Every item cooling down shows at once, a row each: weapons downward from under the weapon hotbar, trinkets upward +// from over the trinket hotbar. A kind's bars are two of its slots long, with its icon just right at its hotbar's +// size, and start far enough off the hotbar that the icons (taller than the bars) clear its slots. Each drains with +// its cooldown and fades with its bar. void Ui::cooldowns_draw(Game& game) { - float width = COOLDOWN_SLOTS * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP; + Fighter& fighter = game.local_get(); for (int kind = 0; kind < 2; kind++) { bool isTrinket = kind == 1; + IconSize size = game.menu.slot_size_get(isTrinket ? fighter.trinketSlots : fighter.weaponSlots); + float height = COOLDOWN_BAR_HEIGHTS[size]; + float icon = ICON_PIXELS[size]; + float width = COOLDOWN_SLOTS * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size]; + float spacing = std::max(icon, height) + COOLDOWN_ROW_GAP; + float start = HOTBAR_MARGIN + SLOT_SIZES[size] + COOLDOWN_GAP + std::ceil(std::max(0.0f, (icon - height) / 2)); CooldownBar* bars = isTrinket ? trinketBars : weaponBars; - int showing = 0; - for (int slot = 0; slot < SLOT_MAX; slot++) - showing += bars[slot].fade > 0; - bool isCompact = showing > COOLDOWN_FULL_ROWS; - float spacing = isCompact ? COOLDOWN_COMPACT_SPACING : COOLDOWN_SPACING; - float iconScale = isCompact ? MINI_ICON_SCALE : 1; - float clearance = std::max(0.0f, (ICON_SIZE * iconScale - COOLDOWN_BAR_HEIGHT) / 2); for (int slot = 0; slot < SLOT_MAX; slot++) { CooldownBar& bar = bars[slot]; if (bar.fade <= 0 || bar.row < 0 || bar.item < 0) continue; float step = std::round(bar.row * spacing); - float y = isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE - COOLDOWN_GAP - COOLDOWN_BAR_HEIGHT - - std::ceil(clearance) - step - : HOTBAR_MARGIN + SLOT_SIZE + COOLDOWN_GAP + std::ceil(clearance) + step; - cooldown_bar_draw(game, {HOTBAR_MARGIN, y, width, COOLDOWN_BAR_HEIGHT}, bar, isTrinket, bar.item, - iconScale); + Rect rect = {HOTBAR_MARGIN, isTrinket ? CANVAS_HEIGHT - start - height - step : start + step, width, + height}; + float middle = rect.y + height / 2; + bar.rect = {rect.x, middle - spacing / 2, width + COOLDOWN_GAP + icon, spacing}; + game.renderer.layer_begin(); + bar_draw(game.renderer, rect, bar.fill, tint, SLOT_STROKES[size]); + icon_draw(game, isTrinket, bar.item, + {.color = tint, .position = {std::round(rect.x + width + COOLDOWN_GAP + icon / 2), middle}}, + size); + game.renderer.layer_end(bar.fade * hover_alpha_get(bar.hover)); } } } -// A bordered bar draining with the cooldown and its item's icon just right of it, at full size so its pixels stay -// whole. Both fade with the bar. -void Ui::cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale) -{ - Color color = color_fade(tint, bar.fade); - bar_draw(game.renderer, rect, bar.fill, color); - float icon = ICON_SIZE * iconScale; - icon_draw(game, isTrinket, item, - {.color = color, - .position = {std::round(rect.x + rect.w + COOLDOWN_GAP + icon / 2), rect.y + rect.h / 2}, - .scale = {iconScale, iconScale}}); -} - // A weapon's or trinket's HUD icon, centred on the placement. void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size) { - Sprites& sprites = game.sprites; - Anm2* sheets[2][ICON_SIZE_COUNT] = {{&sprites.icons, &sprites.iconsSmall, &sprites.iconsTiny}, - {&sprites.trinkets, &sprites.trinketsSmall, &sprites.trinketsTiny}}; - Anm2& art = *sheets[isTrinket][size]; - art.draw(game.renderer, isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, 0, placement); + char name[TOOLTIP_LENGTH]; + SDL_snprintf(name, sizeof(name), "%s%s", isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, + ICON_SUFFIXES[size]); + (isTrinket ? game.sprites.trinkets : game.sprites.icons).draw(game.renderer, name, 0, placement); } // An arrow at the screen's edge for every other living fighter out of view, pointing toward it, in its colour, with @@ -2026,8 +2090,9 @@ void Ui::offscreen_draw(Game& game, Vec2 camera) } } -// Each of this screen's fighters' crosshairs in its colour: at the mouse for the keyboard's (and whenever the menu is -// open), otherwise where its stick aims. With none (a bot playing), the mouse's. +// Each of this screen's fighters' crosshairs in its colour, filling in and shaking with its weapon's charge and +// cooldown: at the mouse for the keyboard's (and whenever the menu is open), otherwise where its stick aims. With none +// (a bot playing), the mouse's. void Ui::crosshairs_draw(Game& game, Vec2 camera) { Input& input = game.input; @@ -2039,9 +2104,13 @@ void Ui::crosshairs_draw(Game& game, Vec2 camera) isAny = true; bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) || (i == game.local && game.menu.isOpen); - Vec2 aim = isMouse ? input.mouse : game.sim.fighters[i].controls.cursor - camera; + Fighter& fighter = game.sim.fighters[i]; + Vec2 aim = (isMouse ? input.mouse : fighter.controls.cursor - camera) + + game.scene.weapon_shake_get(fighter, fighter.weapon_key(fighter.slot)); game.sprites.ui.draw(game.renderer, "Crosshair", 0, - {.color = game.color_get(i), .position = {std::floor(aim.x), std::floor(aim.y)}}); + {.color = game.color_get(i), + .position = {std::floor(aim.x), std::floor(aim.y)}, + .flash = game.scene.charge_fill_get(fighter)}); } if (!isAny) game.sprites.ui.draw(game.renderer, "Crosshair", 0, @@ -2194,16 +2263,21 @@ void Ui::spectate_draw(Game& game) text_center_draw(game, text, y + LINE_HEIGHT, 1, shade); } -// Lays out the other fighters' windows in rows of up to four along the bottom right (more rows stacking upwards, as -// even as they go), never left of the screen's middle (nor over the local hotbar and cooldowns). A window is as wide as -// its icon row, but no wider than one holding two weapons and two trinkets at full size: past that, or when the row -// doesn't fit, icons shrink to small, then tiny. Each kind wraps onto more rows past three across, and tiny icons wrap -// further if they still don't fit. Names and health use the small font, or the tiny one for every window if any doesn't -// fit. +// 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; +} + +// Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more +// than two rows: as many to a row as fit across (the rows as even as they go). A window is as wide as its icons, name +// or health (with its connection's bars, online). Without icons, the health number can go to make room. When they don't +// all fit, the icons shrink to small, then the font to tiny, then the icons to tiny, then the icons are left out. Each +// kind of icon wraps onto more rows past three across, and tiny icons wrap further if they still don't fit. If even +// names and health alone don't fit, the windows split the column evenly and the names are cut short. RivalLayout Ui::rivals_layout(Game& game) { RivalLayout layout; - Fighter& local = game.local_get(); int slots[2] = {}; for (int i = 0; i < game.sim.fighterCount; i++) { @@ -2215,57 +2289,63 @@ RivalLayout Ui::rivals_layout(Game& game) } if (layout.count == 0) return layout; - int cardRows = (layout.count + RIVALS_PER_ROW - 1) / RIVALS_PER_ROW; - int perRow = (layout.count + cardRows - 1) / cardRows; - float step = SLOT_SIZE + SLOT_GAP; - float left = - std::max(CANVAS_WIDTH / 2.0f, - HOTBAR_MARGIN + std::max(local.trinketSlots * step, COOLDOWN_SLOTS * step + ICON_SIZE) + RIVAL_GAP); - float room = CANVAS_WIDTH - HOTBAR_MARGIN - left; + float top = statusRect.h > 0 ? statusRect.y + statusRect.h + RIVAL_GAP : HOTBAR_MARGIN; + float room = CANVAS_HEIGHT - HOTBAR_MARGIN - top; float inset = PANEL_BORDER + RIVAL_PADDING; - auto icons_width = [&](int size, int columns) { - return columns * (ICON_PIXELS[size] + RIVAL_ICON_GAP) + RIVAL_DIVIDER - RIVAL_ICON_GAP; - }; - float widthMax = icons_width(ICON_FULL, RIVAL_FULL_SLOTS) + inset * 2; + bool isRace = game.sim.match.is_racing(); char health[TOOLTIP_LENGTH]; SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.sim.match.health_get())); - float minimum = game.text.size_get(health, FONT_TINY).x + RIVAL_PADDING + RIVAL_BAR_MIN + inset * 2; float width = 0; - for (int size = ICON_FULL; size < ICON_SIZE_COUNT; size++) + float height = 0; + int perRow = 1; + bool isFit = false; + for (int style = 0; style < RIVAL_STYLES && !isFit; style++) { - // No more than a few of a kind side by side, wrapping onto more rows to keep to that; tiny icons can wrap - // further to fit. - int rowsMin = (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX; - int rowsMax = size == ICON_TINY ? SLOT_MAX : rowsMin; - for (int rows = std::max(1, rowsMin); rows <= rowsMax; rows++) + layout.font = RIVAL_FONTS[style]; + layout.isIconShown = style < RIVAL_ICON_STYLES; + layout.size = layout.isIconShown ? RIVAL_SIZES[style] : ICON_TINY; + layout.line = game.text.line_height_get(layout.font); + float text = 0; + float ping = 0; + for (int k = 0; k < layout.count; k++) { + int fighter = layout.fighters[k]; + ping = std::max(ping, rival_ping_get(game, fighter) == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH); + text = std::max(text, game.text.size_get(name_get(game, fighter), layout.font).x); + } + float number = layout.isIconShown ? game.text.size_get(health, layout.font).x + RIVAL_PADDING : 0; + text = isRace ? text + ping : std::max(text, number + RIVAL_BAR_MIN + ping); + + // No more than a few of a kind side by side, wrapping onto more rows to keep to that. Tiny icons can wrap + // further to fit. + float cell = ICON_PIXELS[layout.size] + RIVAL_ICON_GAP; + int rowsMin = std::max(1, (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX); + int rowsMax = layout.isIconShown && layout.size == ICON_TINY ? SLOT_MAX : rowsMin; + for (int rows = rowsMin; rows <= rowsMax && !isFit; rows++) + { + layout.rows = layout.isIconShown ? rows : 0; for (int kind = 0; kind < 2; kind++) layout.columns[kind] = (slots[kind] + rows - 1) / rows; - float icons = icons_width(size, layout.columns[0] + layout.columns[1]) + inset * 2; - width = std::ceil(std::max(icons, minimum)); - layout.size = (IconSize)size; - layout.rows = rows; - bool isFit = icons <= widthMax && perRow * width + (perRow - 1) * RIVAL_GAP <= room; - if (isFit) - size = ICON_SIZE_COUNT; - if (size == ICON_SIZE_COUNT) - break; + float icons = layout.isIconShown + ? (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP + : 0; + width = std::ceil(std::max(icons, text) + inset * 2); + height = inset * 2 + layout.line + (isRace ? 0 : RIVAL_PADDING + layout.line) + + (layout.isIconShown ? RIVAL_PADDING + rows * cell - RIVAL_ICON_GAP : 0); + perRow = std::clamp((int)((HUD_COLUMN_WIDTH + RIVAL_GAP) / (width + RIVAL_GAP)), 1, layout.count); + int cardRows = (layout.count + perRow - 1) / perRow; + perRow = (layout.count + cardRows - 1) / cardRows; + isFit = width <= HUD_COLUMN_WIDTH && cardRows <= RIVAL_ROWS_MAX && + cardRows * (height + RIVAL_GAP) - RIVAL_GAP <= room; } } + if (!isFit) + { + perRow = (layout.count + RIVAL_ROWS_MAX - 1) / RIVAL_ROWS_MAX; + width = std::floor((HUD_COLUMN_WIDTH - (perRow - 1) * RIVAL_GAP) / perRow); + } - float inner = width - inset * 2; - layout.font = game.sim.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner - ? FONT_SMALL - : FONT_TINY; - for (int k = 0; k < layout.count; k++) - if (game.text.size_get(name_get(game, layout.fighters[k])).x > inner) - layout.font = FONT_TINY; - layout.line = game.text.line_height_get(layout.font); - - float healthHeight = game.sim.match.is_racing() ? 0 : layout.line + RIVAL_PADDING; - float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP; - float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset; for (int k = 0; k < layout.count; k++) { int row = k / perRow; @@ -2277,10 +2357,11 @@ RivalLayout Ui::rivals_layout(Game& game) return layout; } -// Every other fighter's window in a match, bordered in its colour: its name centred, its health with a bar beside it, -// then its weapons, a divider, and its trinkets, the weapon in hand lit. A window is dimmed (a fighter out of the round -// more so) until the mouse comes over it, when it eases to full brightness. Hovering an -// icon shows the item's tooltip, as that fighter has it, in its colour. +// Every other fighter's window in a match, bordered in its colour: its name centred (cut short if it doesn't fit), its +// health (online with its connection's bars after), then (with room) its weapons, a divider, and its trinkets, the +// weapon in hand lit. Each is laid over the game as one piece, +// see-through for a fighter out of the round, and fades while the mouse is over it. Hovering an icon shows the item's +// tooltip, as that fighter has it, in its colour. void Ui::rivals_draw(Game& game) { Match& match = game.sim.match; @@ -2299,41 +2380,63 @@ void Ui::rivals_draw(Game& game) int index = layout.fighters[k]; Fighter& fighter = game.sim.fighters[index]; Rect card = layout.cards[k]; - float idle = fighter.isAlive || match.is_racing() ? HUD_IDLE_ALPHA : RIVAL_OUT_ALPHA; - float alpha = index == spectated ? 1 : idle + (1 - idle) * rivalHovers[index]; - // Faded by darkening toward the solid black fill, so the window stays opaque and nothing in it shows through - // anything else. - Color color = color_shade(game.color_get(index), alpha); + float base = fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_ALPHA; + Color color = game.color_get(index); Color dim = color_shade(color, SECONDARY_SHADE); - panel_draw(renderer, card, color, 1); + renderer.layer_begin(); + panel_draw(renderer, card, color); float y = card.y + inset; Font font = layout.font; - const char* name = name_get(game, index); - Vec2 size = game.text.size_get(name, font); - game.text.draw( - renderer, name, - {.color = color, - .position = {std::round(card.x + (card.w - size.x) / 2), std::round(y + (layout.line - size.y) / 2)}}, - font); + // The connection's bars go at the end of the health row, or beside the name in a race. + bool isRace = match.is_racing(); + float ping = rival_ping_get(game, index); + float pingWidth = ping == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH; + float nameRoom = card.w - inset * 2 - (isRace ? pingWidth : 0); + std::string name = name_get(game, index); + std::string shown = name; + while (game.text.size_get(shown.c_str(), font).x > nameRoom && !name.empty()) + { + name.pop_back(); + shown = name + "..."; + } + Vec2 size = game.text.size_get(shown.c_str(), font); + float left = std::round(card.x + (card.w - size.x - (isRace ? pingWidth : 0)) / 2); + game.text.draw(renderer, shown.c_str(), + {.color = color, .position = {left, std::round(y + (layout.line - size.y) / 2)}}, font); + float pingBottom = std::round(y + (layout.line + PING_BARS_HEIGHT) / 2); + if (isRace && ping != PING_NONE) + ping_bars_draw(game, {left + size.x + NAME_PING_GAP, pingBottom}, ping, 1); y += layout.line + RIVAL_PADDING; - if (!match.is_racing()) + // Its health as a bar, with the number before it when there's room. + if (!isRace) { char text[TOOLTIP_LENGTH]; SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); size = game.text.size_get(text, font); float x = card.x + inset; - game.text.draw(renderer, text, - {.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font); - x += size.x + RIVAL_PADDING; + float right = card.x + card.w - inset - pingWidth; + if (size.x + RIVAL_PADDING + RIVAL_BAR_MIN <= right - x) + { + game.text.draw(renderer, text, + {.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font); + x += size.x + RIVAL_PADDING; + } float barHeight = std::min(HEALTH_BAR_HEIGHT, layout.line - 1); - Rect bar = {x, std::round(y + (layout.line - barHeight) / 2), card.x + card.w - inset - x, barHeight}; + Rect bar = {x, std::round(y + (layout.line - barHeight) / 2), right - x, barHeight}; bar_draw(renderer, bar, std::clamp(fighter.health / match.health_get(), 0.0f, 1.0f), color); + if (ping != PING_NONE) + ping_bars_draw(game, {right + NAME_PING_GAP, pingBottom + layout.line + RIVAL_PADDING}, ping, 1); y += layout.line + RIVAL_PADDING; } // Weapons then trinkets, each in its columns, centred with the divider between. + if (!layout.isIconShown) + { + renderer.layer_end(base * hover_alpha_get(rivalHovers[index])); + continue; + } float iconsWidth = (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP; float x = std::round(card.x + (card.w - iconsWidth) / 2); for (int kind = 0; kind < 2; kind++) @@ -2366,6 +2469,7 @@ void Ui::rivals_draw(Game& game) x += RIVAL_DIVIDER + RIVAL_ICON_GAP; } } + renderer.layer_end(base * hover_alpha_get(rivalHovers[index])); } if (hoveredFighter < 0 || game.menu.isOpen) @@ -2375,6 +2479,7 @@ void Ui::rivals_draw(Game& game) int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot]; int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot]; Tooltip tooltip = tooltip_build(game, isTrinket, item, level, hoveredFighter); + tooltip.slot = hoveredSlot; // The tooltip takes that fighter's colour for the moment, so it's clear whose item it is. Color colors[2] = {tint, shade}; tint = game.color_get(hoveredFighter); diff --git a/src/ui.h b/src/ui.h index cd421d3..02d7934 100644 --- a/src/ui.h +++ b/src/ui.h @@ -7,20 +7,25 @@ struct Game; +constexpr int CHAMFER = 2; constexpr int TOOLTIP_LENGTH = 512; constexpr int WEAPON_HANDS = 2; constexpr float HOTBAR_MARGIN = 8; +constexpr float PANEL_BORDER = 2; constexpr float SLOT_GAP = 4; constexpr float SLOT_SIZE = 28; constexpr float WIDGET_GAP = 6; constexpr float WIDGET_HEIGHT = 14; // One item's cooldown bar: how far it has faded in, how much cooldown is left (0 to 1), and the row it sits in among -// the bars showing (eased toward its place as others come and go, -1 while hidden). +// the bars showing (eased toward its place as others come and go, -1 while hidden). Where it was last drawn, and how +// far the mouse being over it has faded it. struct CooldownBar { + Rect rect; float fade = 0; float fill = 0; + float hover = 0; float row = -1; int item = -1; }; @@ -48,9 +53,15 @@ enum IconSize ICON_SIZE_COUNT }; constexpr float ICON_PIXELS[ICON_SIZE_COUNT] = {24, 16, 8}; +// A hotbar slot at each icon size, and the gap between slots. +constexpr float SLOT_SIZES[ICON_SIZE_COUNT] = {SLOT_SIZE, 20, 12}; +constexpr float SLOT_GAPS[ICON_SIZE_COUNT] = {SLOT_GAP, 3, 2}; +// How wide the HUD's columns down the left (hotbars and cooldowns) and right (readouts and other fighters) can be. +constexpr float HUD_COLUMN_WIDTH = CANVAS_WIDTH / 4.0f; // The windows for the other fighters along the bottom right in a match: which fighters, where each window sits, the -// icon size that fits them all, and how many icon columns and rows each kind takes (the same on every window). +// icon size that fits them all (if they show at all), and how many icon columns and rows each kind takes (the same on +// every window). struct RivalLayout { int count = 0; @@ -61,6 +72,7 @@ struct RivalLayout int rows = 1; Font font = FONT_SMALL; float line = 0; + bool isIconShown = true; }; // What a match's countdown shows, gathered once for wherever it's drawn. @@ -80,9 +92,10 @@ struct Tooltip int rowCount = 0; char description[TOOLTIP_LENGTH] = {}; char flavor[TOOLTIP_LENGTH] = {}; - // The item it's about, whose icon it shows top right. + // The item it's about, whose icon it shows top right, numbered with the hotbar slot it's in (-1 for none). bool isTrinket = false; int item = -1; + int slot = -1; }; // How much of each item the round's loadout overview shows, from most to least: everything, everything in the small @@ -141,21 +154,20 @@ struct FocusTarget bool isPreferred = false; }; -// One hotbar or menu box. Item is a weapon or trinket number, or -1 for empty, and label 0 means none. -// Lit boxes draw in the tint, the rest gray. A hoverable box under the mouse lights up with an inner border too. +// One hotbar or menu box, its icon (and the marks on it) at size. Item is a weapon or trinket number, or -1 for empty, +// and label 0 means none. Lit boxes draw in the tint, the rest gray. A hoverable box under the mouse lights up with an +// inner border too. struct Slot { Rect rect; float alpha = 1; + IconSize size = ICON_FULL; int item = -1; int level = 0; int label = 0; bool isHoverable = false; bool isLit = false; bool isTrinket = false; - // Whether it fades by darkening toward its solid black fill rather than turning see-through, so every pixel stays - // opaque and overlapping parts (icon, chevrons, number) don't show through each other. - bool isOpaque = false; // Whether it's the menu's target slot, marked by a bar under it. bool isTarget = false; }; @@ -185,13 +197,14 @@ public: void update(Game& game); void draw(Game& game); void slot_draw(Game& game, Slot slot); - void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1); + void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1, float stroke = PANEL_BORDER); Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, int owner = -1); void stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip); void table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, const float* columns, Font font, float alpha); Vec2 tooltip_size(Game& game, Tooltip& tooltip, Font font = FONT_SMALL); void tooltip_text_draw(Game& game, Tooltip& tooltip, Draw draw, Font font = FONT_SMALL); + void tooltip_icon_draw(Game& game, Tooltip& tooltip, Draw placement); LoadoutLayout loadout_layout(Game& game, float top, float bottom); void tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip); const char* name_get(Game& game, int fighter); @@ -209,6 +222,7 @@ public: bool fold_draw(Game& game, Rect row, const char* label, bool isOpen); void fighter_preview_draw(Game& game, Rect area, const char* name, Color tint, const char* caption); float ping_draw(Game& game, Vec2 corner, float ping, float alpha); + void ping_bars_draw(Game& game, Vec2 corner, float ping, float alpha); void queues_draw(Game& game); void queue_tooltip_draw(Game& game); float ping_width_get(Game& game, float ping); @@ -257,10 +271,12 @@ private: float hintAlpha = 1; // The switching controls as they were last tick, to catch presses while spectating. bool spectateHeld[3] = {}; - // How far each HUD hotbar slot (weapons, then trinkets) and each other fighter's window has come up from dim to - // full brightness, from 0 to 1, rising while the mouse is over it. + // How far the mouse being over each HUD element has changed it, from 0 to 1: hotbar slots (weapons, then trinkets) + // come up from see-through to solid, and the rest (other fighters' windows, the readouts) fade. + Rect statusRect; float slotHovers[2][SLOT_MAX] = {}; float rivalHovers[MAX_FIGHTERS] = {}; + float statusHover = 0; float loadoutTop = 0; float widgetAlpha = 1; int tip = 0; @@ -271,7 +287,7 @@ private: bool isWidgetLive = false; void bars_draw(Game& game); - void bar_draw(Renderer& renderer, Rect bar, float fill, Color color); + void bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float stroke = PANEL_BORDER); void match_draw(Game& game); void loadout_draw(Game& game, float alpha, float top); void race_draw(Game& game); @@ -281,7 +297,6 @@ private: void text_center_draw(Game& game, const char* text, float y, float scale, Color color); void hotbar_draw(Game& game); void cooldowns_draw(Game& game); - void cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale); void icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size = ICON_FULL); void offscreen_draw(Game& game, Vec2 camera); void status_draw(Game& game); @@ -294,7 +309,7 @@ private: void pick_send(Game& game, int fighter, bool isTrinket, int slot, int item); void crosshairs_draw(Game& game, Vec2 camera); void tooltip_draw(Game& game, Tooltip& tooltip); - void chamfer_draw(Renderer& renderer, Rect rect, Color color); + void chamfer_draw(Renderer& renderer, Rect rect, Color color, float cut = CHAMFER); Color widget_color(bool isLit); bool is_hovered(Game& game, Rect box); bool number_widget(Game& game, Rect box, float& value, NumberRange range, void* id); diff --git a/src/weapon.cpp b/src/weapon.cpp index 3cf3953..7ee2f58 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -1,6 +1,7 @@ #include "weapon.h" #include "sim.h" #include +#include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; @@ -8,12 +9,11 @@ constexpr int HEAVY_DAMAGE = 40; constexpr int HOOK_SUBSTEPS = 4; constexpr int TESLA_LINKS = 5; constexpr float TESLA_INTERVAL = 0.2f; -constexpr float TESLA_RADIUS = 4; +constexpr float TESLA_RADIUS = 2; constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220}; constexpr float DEGREE = PI / 180; constexpr float ARROW_LINGER = 2.5f; -constexpr float ARROW_SPAN = 18; -constexpr float ARROW_THICKNESS = 2; +constexpr float ARROW_RADIUS = 6; constexpr float BEAM_RADIUS = 4; constexpr float BOOMERANG_LIFETIME = 6; constexpr float BOOMERANG_RADIUS = 9; @@ -30,8 +30,6 @@ constexpr float TRAJECTORY_REACH = 400; constexpr float TRAJECTORY_STEP = 4; constexpr float FIRE_SHAKE = 0.5f; constexpr float FIREBALL_RADIUS = 5; -constexpr float FLAME_BRAKE = 0.9f; -constexpr float FLAME_BRAKE_REACH = 16; constexpr float FLAIL_BOUNCE = 0.3f; constexpr float FLAIL_BOUNCE_MAX = 0.9f; constexpr float FLAIL_BOUNCE_PER_RICOCHET = 0.2f; @@ -72,7 +70,7 @@ constexpr float PUNCH_RADIUS = 6; constexpr float PUNCH_THRUST = 0.4f; constexpr float SLASH_DISTANCE = 12; constexpr float SLASH_GAP = 14; -constexpr float SHAKE_PER_DAMAGE = 0.006f; +constexpr float SHAKE_PER_DAMAGE = 0.003f; constexpr float SLAM_RATIO = 0.75f; constexpr float SPEED_DAMAGE = 0.05f; // A normal attack let go partway through a charge hits harder, up to this much more just short of full. @@ -418,9 +416,9 @@ int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) // Fires the weapon in hand with the given charge if it's ready. Full charge uses the charged attack. // With the Doubler on, the same attack repeats a moment later for a fraction of the damage (stacked Doublers each -// add a repeat of their own, one after another). With Dual Wield on, the off-hand weapon fires too, for a fraction of -// its damage. Every weapon (see Fighter::weapon_key) keeps its own cooldown, in either hand, so swapping to another -// weapon never inherits the last one's wait. +// add a repeat of their own, one after another). With Dual Wield on, an off-hand weapon per copy fires too (see +// Fighter::off_slots_get), each for a fraction of its damage. Every weapon (see Fighter::weapon_key) keeps its own +// cooldown, in any hand, so swapping to another weapon never inherits the last one's wait. void Weapons::fire(Sim& sim, Fighter& fighter, float charge) { int held = fighter.weapon_key(fighter.slot); @@ -439,13 +437,22 @@ void Weapons::fire(Sim& sim, Fighter& fighter, float charge) } } - int off = fighter.off_slot_get(); - off = off < 0 ? -1 : fighter.weapon_key(off); - float dual = fighter.trinket_get(TRINKET_DUAL, 0); - if (dual > 0 && off >= 0 && off != held && fighter.cooldowns[off] <= 0 && !is_busy(fighter, off)) + // Each Dual Wield copy fires one off-hand weapon at its own level's cut, the strongest cut going to the first. + float powers[SLOT_MAX]; + int copies = 0; + for (int i = 0; i < fighter.trinketSlots; i++) + if (fighter.trinketLoadout[i] == TRINKET_DUAL) + powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].values[fighter.trinketSlotLevels[i]]; + std::sort(powers, powers + copies, std::greater()); + int offs[SLOT_MAX]; + int count = fighter.off_slots_get(offs); + for (int hand = 1; hand <= count; hand++) { + int off = fighter.weapon_key(offs[hand - 1]); + if (fighter.cooldowns[off] > 0 || is_busy(fighter, off)) + continue; is_spawned(sim, fighter, - {.charge = charge, .power = dual, .weaponSlot = off, .isOffhand = true, .isSecondary = true}); + {.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true}); fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves; } } @@ -659,7 +666,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) Attack attack = { .stats = stats, - .origin = fighter.hand_get(), + .origin = fighter.hand_get(shot.hand), .velocity = aim * stats.speed, .aimAngle = vec2_angle(aim), .angle = vec2_angle(aim), @@ -671,9 +678,9 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) .owner = fighter.index, .weapon = held, .key = key, + .hand = shot.hand, .isCharged = isCharged, .isLeft = fighter.is_left(), - .isOffhand = shot.isOffhand, .isSecondary = shot.isSecondary, }; attack.position = attack.origin + aim * MUZZLE_DISTANCE; @@ -763,6 +770,12 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) fighter.index); if (isCharged) sim.cues->shake_add(def.shake * FIRE_SHAKE); + // A fireball goes off with an airblast at the muzzle, as wide as the fireball's own blast. + if (kind == ATTACK_FIREBALL) + blast_spawn(sim, {.position = muzzle, + .area = stats.area * fighter.trinket_get(TRINKET_ENLARGER, 1), + .owner = fighter.index, + .isHarmless = true}); // An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber // softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil. @@ -855,20 +868,6 @@ void Weapons::update(Sim& sim) attack.time += TICK; } tesla_update(sim); - - // Standing in your own lingering flames brakes you, launches included. - for (int i = 0; i < sim.fighterCount; i++) - { - Fighter& fighter = sim.fighters[i]; - for (Attack& attack : attacks) - { - bool isNear = vec2_length(attack.position - fighter.center_get()) < FLAME_BRAKE_REACH; - if (attack.owner != i || attack.def()->kind != ATTACK_FLAME || attack.isDead || !isNear) - continue; - fighter.body.velocity = fighter.body.velocity * FLAME_BRAKE; - break; - } - } attacks_cap(sim); std::erase_if(attacks, [](Attack& attack) { return attack.isDead; }); attacks.insert(attacks.end(), pending.begin(), pending.end()); @@ -1002,7 +1001,10 @@ void Weapons::attack_move(Sim& sim, Attack& attack) // path so they can't skip past targets either. Vec2 start = attack.position; int steps = vec2_length(attack.velocity) * TICK / PROJECTILE_STEP + 1; - attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS : BULLET_RADIUS) * attack.scale; + attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS + : def.kind == ATTACK_ARROW ? ARROW_RADIUS + : BULLET_RADIUS) * + attack.scale; for (int step = 0; step < steps && !attack.isDead; step++) { Vec2 next = attack.position + attack.velocity * (TICK / steps); @@ -1080,7 +1082,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack) attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; if (attack.ricochets == 0) { - attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + attack.origin = fighter.hand_get(attack.hand); attack.angle = vec2_angle(attack.tip - attack.origin); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); } @@ -1091,7 +1093,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack) // once. A plain lash rolls out from the handle, going with the hand, sweeping down across its cone from // the upper edge, and hits anywhere in the cone out to its front until it cracks at full reach (short of // any wall). - attack.origin = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + attack.origin = fighter.hand_get(attack.hand); attack.radius = BEAM_RADIUS * attack.scale; attack.isDead = progress >= 1; if (attack.isCharged) @@ -1151,7 +1153,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack) case ATTACK_SPIN: { // Keeps circling while left is held, finishing the current lap once let go. - int hand = attack.isOffhand ? fighter.off_slot_get() : fighter.slot; + int hand = fighter.hand_slot_get(attack.hand); bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; if (isHeld && attack.time >= attack.duration) { @@ -1500,7 +1502,7 @@ void Weapons::teleport_move(Sim& sim, Attack& attack) attack.isDead = true; Vec2 spot = attack.position; - if (!attack.isCopy && !attack.isOffhand && fighter.isAlive) + if (!attack.isCopy && attack.hand == 0 && fighter.isAlive) { sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner); spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); @@ -1600,7 +1602,7 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) { std::vector paths; int key = fighter.weapon_key(fighter.slot); - if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, false))) + if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, 0))) return paths; const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]]; const LevelDef& level = fighter.level_get(key); @@ -1766,9 +1768,34 @@ void Weapons::attack_explode(Sim& sim, Attack& attack) } // Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every -// fighter away from the centre, harder the closer they are. +// fighter away from the centre, harder the closer they are. A harmless blast (an airblast) only knocks everyone but its +// owner within its area, launching them if close enough, shown as a ring. void Weapons::blast_spawn(Sim& sim, Blast blast) { + float reach = blast.area * (blast.isHarmless ? 1 : KNOCKBACK_REACH); + for (int i = 0; i < sim.fighterCount; i++) + { + Fighter& fighter = sim.fighters[i]; + Vec2 away = fighter.center_get() - blast.position; + float distance = vec2_length(away); + // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback. + float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get(); + bool isPushed = blast.isHarmless ? i != blast.owner : blast.isPushing; + if (!isPushed || !fighter.isAlive || distance >= reach) + continue; + Vec2 push = + vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb; + if (blast.isHarmless) + fighter.knock_take(push, blast.owner); + else + fighter.impulse_add(push); + } + if (blast.isHarmless) + { + sim.cues->ring_spawn(blast.position, reach, blast.owner); + return; + } + Attack attack = { .stats = {.damage = blast.damage, .range = blast.area}, .origin = blast.position, @@ -1785,42 +1812,29 @@ void Weapons::blast_spawn(Sim& sim, Blast blast) }; attack_remember(attack); pending.push_back(attack); - - float reach = blast.area * KNOCKBACK_REACH; - for (int i = 0; i < sim.fighterCount; i++) - { - Fighter& fighter = sim.fighters[i]; - Vec2 away = fighter.center_get() - blast.position; - float distance = vec2_length(away); - // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback. - float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get(); - if (blast.isPushing && fighter.isAlive && distance < reach) - fighter.impulse_add(vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * - (1 - distance / reach) * absorb); - } - sim.cues->sound_play(blast.sound); sim.cues->shake_add(EXPLOSION_DEF.shake); sim.cues->burst_spawn(BLAST_BURST, blast.position, 0, blast.owner); } // The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it. -Attack* Weapons::tether_get(Fighter& fighter, int key, bool isOffhand) +Attack* Weapons::tether_get(Fighter& fighter, int key, int hand) { for (Attack& attack : attacks) { AttackKind kind = attack.def()->kind; if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key && - attack.isOffhand == isOffhand && !attack.isCopy && !attack.isDead) + attack.hand == hand && !attack.isCopy && !attack.isDead) return &attack; } return nullptr; } -// The fighter's own attacks it can stand on or bump into: stuck arrows and charged boomerangs in flight. -std::vector Weapons::solids_get(Fighter& fighter) +// Adds the fighter's own attacks it can stand on to the world for its next move: charged boomerangs in flight as +// obstacles it bumps into, and stuck arrows (until they start fading) as ledges it can only land on from above (and +// drop through). +void Weapons::solids_add(Fighter& fighter, World& world) { - std::vector solids; for (Attack& attack : attacks) { if (attack.owner != fighter.index || attack.isDead) @@ -1829,17 +1843,18 @@ std::vector Weapons::solids_get(Fighter& fighter) if (kind == ATTACK_BOOMERANG && attack.isCharged) { float size = BOOMERANG_RADIUS * attack.scale; - solids.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2}); + world.obstacles.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2}); } - if (kind == ATTACK_ARROW && attack.isAttached) + // A stuck arrow's ledge is the box around its hit shape. + if (kind == ATTACK_ARROW && attack.isAttached && attack.time < attack.duration - ARROW_FADE) { - Vec2 middle = attack.position - vec2_polar(attack.angle, ARROW_SPAN / 2); - Vec2 half = {std::abs(math_cos(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS, - std::abs(math_sin(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS}; - solids.push_back({middle.x - half.x, middle.y - half.y, half.x * 2, half.y * 2}); + Vec2 low = {std::min(attack.position.x, attack.tip.x), std::min(attack.position.y, attack.tip.y)}; + Vec2 high = {std::max(attack.position.x, attack.tip.x), std::max(attack.position.y, attack.tip.y)}; + float radius = attack.radius; + world.ledges.push_back( + {low.x - radius, low.y - radius, high.x - low.x + radius * 2, high.y - low.y + radius * 2}); } } - return solids; } // Whether the fighter has a flail spinning around it. diff --git a/src/weapon.h b/src/weapon.h index 2879a7d..b93ba0d 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -13,6 +13,7 @@ constexpr float WHIP_UNFURL = 0.6f; class Fighter; struct Dummy; struct Sim; +class World; constexpr int LEVEL_COUNT = 3; constexpr int WEAPON_COUNT = 16; @@ -96,6 +97,8 @@ struct LevelDef int limit = 0; }; +// How long a stuck arrow fades out at the end of its life, no longer a ledge to stand on. +constexpr float ARROW_FADE = 0.6f; constexpr float ARROW_GRAVITY = 500; constexpr float MINE_ARM_TIME = 0.3f; constexpr float ROCKET_ACCELERATION = 600; @@ -199,13 +202,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_SLASH, .sound = SOUND_SLASH, .duration = 0.3f, - .shake = 0.15f, + .shake = 0.075f, .spread = 7}, .charged = {.label = "flying slash", .kind = ATTACK_SLASH, .sound = SOUND_SLASH_HEAVY, .duration = 0.6f, - .shake = 0.45f, + .shake = 0.225f, .spread = 5}, .levels = { @@ -231,13 +234,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_SWING, .sound = SOUND_SWING, .duration = 0.3f, - .shake = 0.3f, + .shake = 0.15f, .spread = 6}, .charged = {.label = "slam", .kind = ATTACK_SWING, .sound = SOUND_SWING_HEAVY, .duration = 0.45f, - .shake = 0.7f, + .shake = 0.35f, .spread = 6}, .levels = { @@ -253,9 +256,9 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .chargeBlurb = "Charge to lunge toward the cursor\nwith a harder poke.", .flavor = "Works best when riding an ostrich.", .hit = HIT_PIERCE, - .normal = {.label = "poke", .kind = ATTACK_POKE, .sound = SOUND_POKE, .shake = 0.15f, .spread = 4}, + .normal = {.label = "poke", .kind = ATTACK_POKE, .sound = SOUND_POKE, .shake = 0.075f, .spread = 4}, .charged = - {.label = "lunging poke", .kind = ATTACK_POKE, .sound = SOUND_POKE_HEAVY, .shake = 0.4f, .spread = 3}, + {.label = "lunging poke", .kind = ATTACK_POKE, .sound = SOUND_POKE_HEAVY, .shake = 0.2f, .spread = 3}, .levels = { {.normal = {.damage = 12, .range = 90, .speed = 500}, @@ -280,13 +283,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_PUNCH, .sound = SOUND_PUNCH, .duration = 0.12f, - .shake = 0.05f, + .shake = 0.025f, .spread = 18}, .charged = {.label = "uppercut", .kind = ATTACK_UPPERCUT, .sound = SOUND_UPPERCUT, .duration = 0.35f, - .shake = 0.5f, + .shake = 0.25f, .spread = 5}, .levels = { @@ -314,13 +317,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_SPIN, .sound = SOUND_FLAIL, .duration = 0.4f, - .shake = 0.15f, + .shake = 0.075f, .spread = 8, .hits = 99}, .charged = {.label = "ball throw", .kind = ATTACK_FLAIL, .sound = SOUND_FLAIL_HEAVY, - .shake = 0.3f, + .shake = 0.15f, .spread = 6, .hits = 99}, .levels = @@ -349,13 +352,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_WHIP, .sound = SOUND_WHIP, .duration = 0.22f, - .shake = 0.1f, + .shake = 0.05f, .spread = 7.5f}, .charged = {.label = "lash + blast", .kind = ATTACK_WHIP, .sound = SOUND_WHIP_HEAVY, .duration = 0.12f, - .shake = 0.3f, + .shake = 0.15f, .spread = 0.5f, .hits = 0}, .levels = @@ -378,11 +381,11 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .chargeBlurb = "Charge to plunge the hook in\ninstantly and get yanked to it.", .flavor = "Get over here!", .hit = HIT_PIERCE, - .normal = {.label = "hook", .kind = ATTACK_HOOK, .sound = SOUND_GRAPPLE, .shake = 0.2f, .spread = 2}, + .normal = {.label = "hook", .kind = ATTACK_HOOK, .sound = SOUND_GRAPPLE, .shake = 0.1f, .spread = 2}, .charged = {.label = "yanking hook", .kind = ATTACK_HOOK, .sound = SOUND_GRAPPLE_HEAVY, - .shake = 0.4f, + .shake = 0.2f, .spread = 1}, .levels = { @@ -413,7 +416,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_BOOMERANG, .sound = SOUND_BOOMERANG, .duration = 0.35f, - .shake = 0.15f, + .shake = 0.075f, .spread = 8, .hits = 2}, .charged = {.label = "big boomerang", @@ -422,7 +425,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .duration = 0.4f, .hold = 1.2f, .scale = 2, - .shake = 0.3f, + .shake = 0.15f, .spread = 6, .hits = 4}, .levels = @@ -448,9 +451,9 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .chargeBlurb = "The longer the draw, the faster\nand harder they fly.", .flavor = "Erm, actually, bows require\nimmense upper body strengt-", .hit = HIT_PIERCE, - .normal = {.label = "arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW, .shake = 0.05f, .spread = 6}, + .normal = {.label = "arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW, .shake = 0.025f, .spread = 6}, .charged = - {.label = "drawn arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW_HEAVY, .shake = 0.3f, .spread = 3}, + {.label = "drawn arrow", .kind = ATTACK_ARROW, .sound = SOUND_BOW_HEAVY, .shake = 0.15f, .spread = 3}, .levels = { {.normal = {.damage = 22, .range = 360, .speed = 320, .volley = 1}, @@ -474,11 +477,11 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .hit = HIT_BULLET, .flash = 0.7f, .kick = -0.15f, - .normal = {.label = "bullet", .kind = ATTACK_BULLET, .sound = SOUND_UZI, .shake = 0.02f, .spread = 7}, + .normal = {.label = "bullet", .kind = ATTACK_BULLET, .sound = SOUND_UZI, .shake = 0.01f, .spread = 7}, .charged = {.label = "bullet", .kind = ATTACK_BULLET, .sound = SOUND_UZI_HEAVY, - .shake = 0.25f, + .shake = 0.125f, .spread = 6, .scatter = 1.1f}, .levels = @@ -510,14 +513,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .normal = {.label = "pellet", .kind = ATTACK_BULLET, .sound = SOUND_SHOTGUN, - .shake = 0.3f, + .shake = 0.15f, .spread = 6, .scatter = 2.3f}, .charged = {.label = "slug", .kind = ATTACK_BULLET, .sound = SOUND_SHOTGUN_HEAVY, .scale = 2.5f, - .shake = 0.5f, + .shake = 0.25f, .spread = 4, .hits = 3}, .levels = @@ -544,12 +547,12 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .hit = HIT_ENERGY, .flash = 1, .kick = -0.5f, - .normal = {.label = "bolt", .kind = ATTACK_PROJECTILE, .sound = SOUND_BLAST, .shake = 0.08f, .spread = 2}, + .normal = {.label = "bolt", .kind = ATTACK_PROJECTILE, .sound = SOUND_BLAST, .shake = 0.04f, .spread = 2}, .charged = {.label = "beam", .kind = ATTACK_BEAM, .sound = SOUND_BEAM, .duration = 0.3f, - .shake = 0.35f, + .shake = 0.175f, .spread = 0.5f}, .levels = { @@ -568,24 +571,29 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .name = "Flamethrower", .blurb = "Sprays flames that drift up and\nlinger, burning whatever they touch.", - .chargeBlurb = "Charge to hurl a giant fireball\nthat explodes on impact or after\na short distance.", + .chargeBlurb = "Charge to hurl a giant fireball\nthat explodes on impact or after\na short distance, with " + "an airblast\nthat shoves fighters near you away.", .flavor = "Mmmph mmmph mmph!!", .hit = HIT_FIRE, .kick = -0.1f, - .normal = - {.label = "flame", .kind = ATTACK_FLAME, .sound = SOUND_FLAME, .shake = 0.01f, .spread = 16, .hits = 2}, + .normal = {.label = "flame", + .kind = ATTACK_FLAME, + .sound = SOUND_FLAME, + .shake = 0.005f, + .spread = 16, + .hits = 2}, .charged = - {.label = "fireball", .kind = ATTACK_FIREBALL, .sound = SOUND_FIREBALL, .shake = 0.4f, .spread = 6}, + {.label = "fireball", .kind = ATTACK_FIREBALL, .sound = SOUND_FIREBALL, .shake = 0.2f, .spread = 6}, .levels = { {.normal = {.damage = 2, .duration = 1.5f, .range = 115, .speed = 500}, - .charged = {.area = 40, .damage = 30, .range = 160, .scale = 2, .speed = 300}, + .charged = {.area = 40, .damage = 30, .lunge = -110, .range = 160, .scale = 1, .speed = 300}, .cooldown = 0.08f}, {.normal = {.damage = 3, .duration = 2.25f, .range = 140, .speed = 650}, - .charged = {.area = 55, .damage = 42, .range = 180, .scale = 2.5f, .speed = 300}, + .charged = {.area = 55, .damage = 42, .lunge = -140, .range = 180, .scale = 1.25f, .speed = 300}, .cooldown = 0.08f}, {.normal = {.damage = 4, .duration = 3, .range = 165, .speed = 800}, - .charged = {.area = 70, .damage = 54, .range = 200, .scale = 3, .speed = 300}, + .charged = {.area = 70, .damage = 54, .lunge = -165, .range = 200, .scale = 1.5f, .speed = 300}, .cooldown = 0.08f}, }, .bot = {.rangeMax = 110}, @@ -597,9 +605,9 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .flavor = "RAWKET LAWNCHAIR", .hit = HIT_EXPLOSIVE, .kick = -0.6f, - .normal = {.label = "rocket", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET, .shake = 0.15f, .spread = 5}, + .normal = {.label = "rocket", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET, .shake = 0.075f, .spread = 5}, .charged = - {.label = "missile", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET_HEAVY, .shake = 0.3f, .spread = 4}, + {.label = "missile", .kind = ATTACK_ROCKET, .sound = SOUND_ROCKET_HEAVY, .shake = 0.15f, .spread = 4}, .levels = { {.normal = {.area = 28, .damage = 14, .lunge = -120, .range = 500, .speed = 120}, @@ -627,7 +635,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_MINE, .sound = SOUND_MINE, .duration = 0.25f, - .shake = 0.1f, + .shake = 0.05f, .spread = 6, .hits = 0}, .levels = @@ -663,13 +671,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = .kind = ATTACK_WISP, .sound = SOUND_CAST, .duration = 0.35f, - .shake = 0.05f, + .shake = 0.025f, .spread = 4}, .charged = {.label = "teleport", .kind = ATTACK_TELEPORT, .sound = SOUND_TELEPORT_WARP, .duration = 0.175f, - .shake = 0.3f}, + .shake = 0.15f}, .levels = { {.normal = {.damage = 24, .range = 320, .speed = 330, .volley = 1}, @@ -717,7 +725,7 @@ struct AttackSummary AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool isCharged); // A blast's attack: an explosion with no weapon of its own to describe it. -inline constexpr AttackDef EXPLOSION_DEF = {.kind = ATTACK_EXPLOSION, .duration = 0.35f, .shake = 0.5f}; +inline constexpr AttackDef EXPLOSION_DEF = {.kind = ATTACK_EXPLOSION, .duration = 0.35f, .shake = 0.25f}; // A live attack, carrying its level's numbers from when it was fired. Secondary attacks (off-hand and Doubler) // draw gray. Bounces is how many ricochets it has @@ -762,6 +770,8 @@ struct Attack int ricochets = 0; int weapon = 0; int key = -1; + // The hand it came from: the main hand (0) or a Dual Wield off-hand (1 up). + int hand = 0; bool isAttached = false; bool isCharged = false; bool isCopy = false; @@ -770,7 +780,6 @@ struct Attack bool isGathering = false; bool isGentle = false; bool isLeft = false; - bool isOffhand = false; bool isPulling = false; bool isRebounded = false; bool isReturning = false; @@ -825,6 +834,7 @@ struct Blast SoundId sound; int weapon = -1; bool isGentle = false; + bool isHarmless = false; bool isPushing = false; bool isSecondary = false; }; @@ -842,8 +852,8 @@ struct Shot float charge = 0; float power = 1; int weaponSlot = 0; + int hand = 0; bool isEcho = false; - bool isOffhand = false; bool isSecondary = false; }; @@ -891,7 +901,7 @@ public: void wisp_move(Sim& sim, Attack& attack); void wisps_release(Fighter& fighter); void teleport_move(Sim& sim, Attack& attack); - std::vector solids_get(Fighter& fighter); + void solids_add(Fighter& fighter, World& world); private: std::vector pending; @@ -902,7 +912,7 @@ private: void attack_hit(Sim& sim, Attack& attack, int target, Vec2 point); std::vector chain_get(Sim& sim, Fighter& fighter); void tesla_update(Sim& sim); - Attack* tether_get(Fighter& fighter, int key, bool isOffhand); + Attack* tether_get(Fighter& fighter, int key, int hand); void hook_attach(Sim& sim, Attack& attack); void piece_launch(Sim& sim, Attack& piece); void attack_explode(Sim& sim, Attack& attack); diff --git a/src/world.cpp b/src/world.cpp index 8ee0823..d06af49 100644 --- a/src/world.cpp +++ b/src/world.cpp @@ -426,10 +426,10 @@ void World::body_move(Body& body) } // One step of movement, resolving x then y against the grid and any obstacles (which only stop bodies moving into -// them, never ones already inside). Platforms only catch bodies falling onto them. Then ramps: steep ones push bodies -// out sideways, walkable ones carry the feet, and grounded bodies stay stuck to the ground going over crests and down -// slopes unless they're jumping. A grounded body steps up a ledge as low as a walkable slope leaves under its edge, -// which carries it from a ramp's crest onto the ground beside it. +// them, never ones already inside). Platforms, and ledges like them, only catch bodies falling onto them. Then ramps: +// steep ones push bodies out sideways, walkable ones carry the feet, and grounded bodies stay stuck to the ground going +// over crests and down slopes unless they're jumping. A grounded body steps up a ledge as low as a walkable slope +// leaves under its edge, which carries it from a ramp's crest onto the ground beside it. void World::body_step(Body& body, float time) { Rect& box = body.box; @@ -484,6 +484,16 @@ void World::body_step(Body& body, float time) body.isGrounded |= isDown; body.velocity.y = 0; } + for (Rect& ledge : ledges) + { + bool isAcross = box.x < ledge.x + ledge.w && box.x + box.w > ledge.x; + bool isLanding = isDown && !body.isDropping && previousBottom <= ledge.y + BODY_EPSILON; + if (!isAcross || !isLanding || box.y + box.h <= ledge.y) + continue; + box.y = ledge.y - box.h; + body.isGrounded = true; + body.velocity.y = 0; + } body.slope = 0; ramp_push(body); @@ -624,13 +634,12 @@ bool World::is_solid_at(Vec2 point) return is_solid(tile); } -// What attacks collide with and hooks grab: solid tiles anywhere, and platforms within their drawn strip. Bodies -// still pass up through platforms and can drop through them. +// What attacks collide with and hooks grab: whatever's solid (slopes below their ramp's line included), and platforms +// within their drawn strip. Bodies still pass up through platforms and can drop through them. bool World::is_blocking_at(Vec2 point) { - Tile tile = tile_at(point); - return is_solid(tile) || - (tile == TILE_PLATFORM && point.y - std::floor(point.y / TILE_SIZE) * TILE_SIZE < PLATFORM_HEIGHT); + return is_solid_at(point) || + (tile_at(point) == TILE_PLATFORM && point.y - std::floor(point.y / TILE_SIZE) * TILE_SIZE < PLATFORM_HEIGHT); } // Whether any part of the box overlaps a tile of the given kind. diff --git a/src/world.h b/src/world.h index 359c769..03a53af 100644 --- a/src/world.h +++ b/src/world.h @@ -84,12 +84,14 @@ constexpr int SPAWN_MAX = 16; // Tile grid of any size, its collision, and the things standing in it. Spawns are where fighters stand at the start // of a round, marked 1 to 8 in the map. Obstacles are extra solid boxes for the body moving right now, like a -// fighter's own solid attacks. The map is the deathmatch arena or a race course stitched from chunks. Ramp tiles are -// solid below their ramp's line, and bodies meet ramps by their own rules rather than the grid's. +// fighter's own solid attacks, and ledges extra boxes it can only land on from above, like platforms. The map is the +// deathmatch arena or a race course stitched from chunks. Ramp tiles are solid below their ramp's line, and bodies meet +// ramps by their own rules rather than the grid's. class World { public: std::vector dummies; + std::vector ledges; std::vector obstacles; std::vector ramps; std::vector tiles; diff --git a/tools/fonts_export.py b/tools/fonts_export.py index fcf5a3f..3b7a9d5 100644 --- a/tools/fonts_export.py +++ b/tools/fonts_export.py @@ -26,6 +26,19 @@ FONTS = ROOT / "resources" / "font" TTF = FONTS / "pixel_purl.ttf" FIRST = 32 COUNT = 95 +# Characters past ASCII, each after the last in its own cell (written into the .fnt as UTF-8). +EXTRA = "°←↑→↓" +# Small glyphs drawn here instead of taken from the TTF: the degree sign a pixel wider than its own, and arrows (the +# TTF has none), the up one turned for the rest. +ARROW_UP = ["..##..", ".####.", "######", "..##..", "..##..", "..##.."] +ARROW_RIGHT = ["".join(ARROW_UP[len(ARROW_UP) - 1 - y][x] for y in range(len(ARROW_UP))) for x in range(len(ARROW_UP[0]))] +SMALL_DRAWN = { + "°": {"rows": [".##.", "#..#", "#..#", "#..#", ".##."], "left": 0, "top": 2, "advance": 5}, + "←": {"rows": [row[::-1] for row in ARROW_RIGHT], "left": 0, "top": 5, "advance": 7}, + "↑": {"rows": ARROW_UP, "left": 0, "top": 5, "advance": 7}, + "→": {"rows": ARROW_RIGHT, "left": 0, "top": 5, "advance": 7}, + "↓": {"rows": ARROW_UP[::-1], "left": 0, "top": 5, "advance": 7}, +} COLUMNS = 16 EM_PIXELS = 16 LINE_HEIGHT = 12 @@ -77,12 +90,14 @@ TINY = { 'v': ["...", "#.#", "#.#", "#.#", ".#."], 'w': [".....", "#...#", "#.#.#", "#.#.#", ".#.#."], 'x': ["...", "#.#", ".#.", ".#.", "#.#"], 'y': ["...", "#.#", "#.#", ".##", "..#", "##."], 'z': ["...", "###", ".#.", "#..", "###"], '{': [".##", ".#.", "##.", ".#.", ".##"], '|': ["#", "#", "#", "#", "#"], - '}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."], + '}': ["##.", ".#.", ".##", ".#.", "##."], '~': ["....", ".#.#", "#.#."], '°': [".##.", "#..#", ".##."], + '←': ["....", ".#..", "####", ".#.."], '↑': [".#.", "###", ".#.", ".#."], '→': ["....", "..#.", "####", "..#."], + '↓': ["...", ".#.", ".#.", "###", ".#."], } def characters(): - return [chr(FIRST + i) for i in range(COUNT)] + return [chr(FIRST + i) for i in range(COUNT)] + list(EXTRA) # Each glyph as rows of '#'/'.' with where its top-left sits relative to the pen at the line's top, and its advance. @@ -97,8 +112,10 @@ def tiny_glyphs(): def small_glyphs(): font = ImageFont.truetype(str(TTF), EM_PIXELS) - glyphs = {} + glyphs = dict(SMALL_DRAWN) for character in characters(): + if character in glyphs: + continue # Drawn with the line's top at the origin's y, as the game always has; the bounding box gives the pixels. image = Image.new("L", (64, 64), 0) draw = ImageDraw.Draw(image) @@ -127,7 +144,7 @@ def font_write(name, glyphs, scale, line_height, outline): right = max(glyph["left"] + len(glyph["rows"][0]) for glyph in glyphs.values() if glyph["rows"]) bottom = max(glyph["top"] + len(glyph["rows"]) for glyph in glyphs.values() if glyph["rows"]) cell_w, cell_h = right - left, max(bottom, line_height) - rows = (COUNT + COLUMNS - 1) // COLUMNS + rows = (len(glyphs) + COLUMNS - 1) // COLUMNS image = Image.new("RGBA", (COLUMNS * (cell_w + 1), rows * (cell_h + 1)), (0, 0, 0, 0)) entries = {} for i, character in enumerate(characters()): @@ -145,10 +162,10 @@ def font_write(name, glyphs, scale, line_height, outline): if scale is None: del header["scale"] lines = ["{"] + [f' {json.dumps(key)}: {json.dumps(value)},' for key, value in header.items()] + [' "glyphs": {'] - lines += [f' {json.dumps(character)}: {json.dumps(entry)}{"," if i < COUNT - 1 else ""}' + lines += [f' {json.dumps(character, ensure_ascii=False)}: {json.dumps(entry)}{"," if i < len(entries) - 1 else ""}' for i, (character, entry) in enumerate(entries.items())] lines += [" }", "}"] - (FONTS / f"{name}.fnt").write_text("\n".join(lines) + "\n") + (FONTS / f"{name}.fnt").write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"{name}: {image.width}x{image.height}, cells {cell_w}x{cell_h}") diff --git a/tools/icons_resize.py b/tools/icons_resize.py deleted file mode 100644 index 2d32379..0000000 --- a/tools/icons_resize.py +++ /dev/null @@ -1,133 +0,0 @@ -# Makes the small (16x16) and tiny (8x8) icon sheets from the full 24x24 weapon and trinket icon sheets, with an anm2 -# for each listing the same regions and animations at the new size. -# -# The icons are a black body with grey detail inside a one pixel white outline, all tinted when drawn. Shrinking that -# outline along with the rest would smear it, so only the body is shrunk (a cell is body where enough of what it covers -# was, grey where most of that was grey) and a fresh one pixel outline is drawn around it at the new size. Tiny icons -# have no room for that, so they're the shrunk silhouette in white, keeping black only where it's thick enough. -# -# python tools/icons_resize.py - -import json -import pathlib - -from PIL import Image - -ROOT = pathlib.Path(__file__).resolve().parent.parent -GFX = ROOT / "resources" / "gfx" -SHEETS = ["weapon_icons", "trinkets"] -SIZES = {"small": 16, "tiny": 8} -CELL = 24 -BODY_COVERAGE = 0.4 -SHAPE_COVERAGE = 0.5 -INNER_COVERAGE = 0.6 -WHITE = (255, 255, 255, 255) -GREY = (128, 128, 128, 255) -BLACK = (0, 0, 0, 255) - - -def is_body(pixel): - return pixel[3] > 0 and pixel[:3] != (255, 255, 255) - - -def coverage_get(source, x, y, scale): - """How much of a cell at the new size was opaque, body, and grey body, as shares of the cell.""" - opaque = covered = grey = total = 0.0 - for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))): - for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))): - # How much of the source pixel falls inside this cell. - weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \ - (min(sy + 1, (y + 1) * scale) - max(sy, y * scale)) - total += weight - pixel = source[sx, sy] - opaque += weight * (pixel[3] > 0) - if is_body(pixel): - covered += weight - grey += weight * (pixel[:3] == (128, 128, 128)) - return opaque / total, covered / total, grey / total - - -def icon_tiny(icon, size): - """Too small for an outline around a body: the whole silhouette in white, with black (or grey) left only where it's - thick enough to keep a white rim all round.""" - source = icon.load() - scale = CELL / size - cells = [[coverage_get(source, x, y, scale) for x in range(size)] for y in range(size)] - shape = [[cells[y][x][0] >= SHAPE_COVERAGE for x in range(size)] for y in range(size)] - result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = result.load() - for y in range(size): - for x in range(size): - if not shape[y][x]: - continue - opaque, covered, grey = cells[y][x] - inner = all(0 <= x + dx < size and 0 <= y + dy < size and shape[y + dy][x + dx] - for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))) - if inner and covered >= INNER_COVERAGE: - pixels[x, y] = GREY if grey > covered / 2 else BLACK - else: - pixels[x, y] = WHITE - return result - - -def icon_shrink(icon, size): - source = icon.load() - scale = CELL / size - body = [[None] * size for _ in range(size)] - for y in range(size): - for x in range(size): - covered = grey = total = 0.0 - for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))): - for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))): - # How much of the source pixel falls inside this cell. - weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \ - (min(sy + 1, (y + 1) * scale) - max(sy, y * scale)) - total += weight - pixel = source[sx, sy] - if is_body(pixel): - covered += weight - grey += weight * (pixel[:3] == (128, 128, 128)) - if covered / total >= BODY_COVERAGE: - body[y][x] = GREY if grey > covered / 2 else BLACK - - result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = result.load() - for y in range(size): - for x in range(size): - if body[y][x]: - pixels[x, y] = body[y][x] - continue - touching = any(0 <= x + dx < size and 0 <= y + dy < size and body[y + dy][x + dx] - for dx in (-1, 0, 1) for dy in (-1, 0, 1)) - if touching: - pixels[x, y] = WHITE - return result - - -def main(): - for sheet in SHEETS: - art = json.loads((GFX / f"{sheet}.anm2").read_text()) - image = Image.open(GFX / f"{sheet}.png").convert("RGBA") - for suffix, size in SIZES.items(): - columns, rows = image.width // CELL, image.height // CELL - result = Image.new("RGBA", (columns * size, rows * size), (0, 0, 0, 0)) - for row in range(rows): - for column in range(columns): - icon = image.crop((column * CELL, row * CELL, (column + 1) * CELL, (row + 1) * CELL)) - shrink = icon_tiny if suffix == "tiny" else icon_shrink - result.paste(shrink(icon, size), (column * size, row * size)) - name = f"{sheet}_{suffix}" - result.save(GFX / f"{name}.png") - - shrunk = json.loads(json.dumps(art)) - spritesheet = shrunk["spritesheets"][0] - spritesheet["path"] = f"{name}.png" - for region in spritesheet["regions"]: - region["crop"] = [region["crop"][0] // CELL * size, region["crop"][1] // CELL * size] - region["size"] = [size, size] - (GFX / f"{name}.anm2").write_text(json.dumps(shrunk, indent=2) + "\n") - print(f"{name}: {result.width}x{result.height}") - - -if __name__ == "__main__": - main()