From 3b2af362316c2246121ed625ec4e197448b9d291 Mon Sep 17 00:00:00 2001 From: Elrinth Date: Fri, 30 Jan 2026 08:31:33 +0100 Subject: [PATCH] added so you can choose race when starting the game. --- src/assets/fonts/dmg_numbers.png | Bin 2650 -> 2753 bytes .../gfx/character_select/characters.png | Bin 0 -> 42252 bytes .../character_select/characters.png.import | 40 + .../gfx/character_select/characters2.png | Bin 0 -> 16749 bytes .../character_select/characters2.png.import | 40 + src/assets/gfx/character_select/dwarf.png | Bin 0 -> 12558 bytes .../gfx/character_select/dwarf.png.import | 40 + src/assets/gfx/character_select/elf.png | Bin 0 -> 12288 bytes .../gfx/character_select/elf.png.import | 40 + src/assets/gfx/character_select/wizard.png | Bin 0 -> 16169 bytes .../gfx/character_select/wizard.png.import | 40 + .../boss/SpiderBat/Spiderbat death.png | Bin 0 -> 31624 bytes .../boss/SpiderBat/Spiderbat death.png.import | 40 + .../gfx/enemies/boss/SpiderBat/down_right.png | Bin 0 -> 18436 bytes .../boss/SpiderBat/down_right.png.import | 40 + .../enemies/boss/SpiderBat/flying_down.png | Bin 0 -> 36682 bytes .../boss/SpiderBat/flying_down.png.import | 40 + .../gfx/enemies/boss/SpiderBat/right.png | Bin 0 -> 16101 bytes .../enemies/boss/SpiderBat/right.png.import | 40 + src/project.godot | 1 + src/scenes/attack_axe_swing.tscn | 606 ++++++++- src/scenes/attack_punch.tscn | 43 + src/scenes/attack_spell_frostspike.tscn | 1 + src/scenes/damage_effect_arrow.tscn | 14 + src/scenes/damage_effect_axe.tscn | 14 + src/scenes/damage_effect_bite.tscn | 14 + src/scenes/damage_effect_punch.tscn | 14 + src/scenes/damage_effect_slash.tscn | 14 + src/scenes/game_world.tscn | 4 +- src/scenes/main_menu.tscn | 8 + src/scenes/player.tscn | 12 +- src/scenes/select_class.tscn | 130 ++ src/scripts/attack_arrow.gd | 198 ++- src/scripts/attack_axe_swing.gd | 203 ++- src/scripts/attack_bomb.gd | 9 + src/scripts/attack_punch.gd | 135 ++ src/scripts/attack_punch.gd.uid | 1 + src/scripts/attack_spell_flame.gd | 39 +- src/scripts/attack_spell_frostspike.gd | 10 +- src/scripts/character_stats.gd | 8 +- src/scripts/damage_effect_arrow.gd | 27 + src/scripts/damage_effect_arrow.gd.uid | 1 + src/scripts/damage_effect_axe.gd | 27 + src/scripts/damage_effect_axe.gd.uid | 1 + src/scripts/damage_effect_bite.gd | 27 + src/scripts/damage_effect_bite.gd.uid | 1 + src/scripts/damage_effect_punch.gd | 27 + src/scripts/damage_effect_punch.gd.uid | 1 + src/scripts/damage_effect_slash.gd | 27 + src/scripts/damage_effect_slash.gd.uid | 1 + src/scripts/enemy_base.gd | 223 ++-- src/scripts/enemy_hand.gd | 44 +- src/scripts/enemy_humanoid.gd | 119 +- src/scripts/game_state.gd | 9 + src/scripts/game_state.gd.uid | 1 + src/scripts/game_ui.gd | 241 +++- src/scripts/game_world.gd | 1084 +++++++++++++---- src/scripts/interactable_object.gd | 39 +- src/scripts/inventory_ui.gd | 24 +- src/scripts/item_database.gd | 413 ++++--- src/scripts/loot.gd | 121 +- src/scripts/matchbox_client.gd | 22 + src/scripts/network_manager.gd | 9 + src/scripts/player.gd | 735 +++++++---- src/scripts/player_manager.gd | 10 +- src/scripts/room_trigger.gd | 10 + src/scripts/select_class.gd | 96 ++ src/scripts/select_class.gd.uid | 1 + src/scripts/staff_projectile.gd | 16 +- src/scripts/stairs.gd | 4 - src/scripts/sword_projectile.gd | 48 +- src/scripts/sword_slash.gd | 37 +- src/scripts/trap.gd | 64 + 73 files changed, 4241 insertions(+), 1107 deletions(-) create mode 100644 src/assets/gfx/character_select/characters.png create mode 100644 src/assets/gfx/character_select/characters.png.import create mode 100644 src/assets/gfx/character_select/characters2.png create mode 100644 src/assets/gfx/character_select/characters2.png.import create mode 100644 src/assets/gfx/character_select/dwarf.png create mode 100644 src/assets/gfx/character_select/dwarf.png.import create mode 100644 src/assets/gfx/character_select/elf.png create mode 100644 src/assets/gfx/character_select/elf.png.import create mode 100644 src/assets/gfx/character_select/wizard.png create mode 100644 src/assets/gfx/character_select/wizard.png.import create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png.import create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/down_right.png create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/down_right.png.import create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/flying_down.png create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/flying_down.png.import create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/right.png create mode 100644 src/assets/gfx/enemies/boss/SpiderBat/right.png.import create mode 100644 src/scenes/attack_punch.tscn create mode 100644 src/scenes/damage_effect_arrow.tscn create mode 100644 src/scenes/damage_effect_axe.tscn create mode 100644 src/scenes/damage_effect_bite.tscn create mode 100644 src/scenes/damage_effect_punch.tscn create mode 100644 src/scenes/damage_effect_slash.tscn create mode 100644 src/scenes/select_class.tscn create mode 100644 src/scripts/attack_punch.gd create mode 100644 src/scripts/attack_punch.gd.uid create mode 100644 src/scripts/damage_effect_arrow.gd create mode 100644 src/scripts/damage_effect_arrow.gd.uid create mode 100644 src/scripts/damage_effect_axe.gd create mode 100644 src/scripts/damage_effect_axe.gd.uid create mode 100644 src/scripts/damage_effect_bite.gd create mode 100644 src/scripts/damage_effect_bite.gd.uid create mode 100644 src/scripts/damage_effect_punch.gd create mode 100644 src/scripts/damage_effect_punch.gd.uid create mode 100644 src/scripts/damage_effect_slash.gd create mode 100644 src/scripts/damage_effect_slash.gd.uid create mode 100644 src/scripts/game_state.gd create mode 100644 src/scripts/game_state.gd.uid create mode 100644 src/scripts/select_class.gd create mode 100644 src/scripts/select_class.gd.uid diff --git a/src/assets/fonts/dmg_numbers.png b/src/assets/fonts/dmg_numbers.png index d721530525562ae4cf748ee671565cd74cb29326..765e63b50b52cd8df2becc6b62827cdac5309a94 100644 GIT binary patch delta 1293 zcmV+o1@ijZ6u}j+#sz=oNklJ~ENfB?8IvX+4TNq!h~)&Y9f0q-RsQ=kf7Z^!Gxw}e!}`!(KU z1c1B$NGbux-!Gug?)%1`4aRFg??d6mG%sR!5TRp@DZhX2`~4{a zoeyJxZBTkl4gvY`wQM*IKneAn4|Noc`DZTO&=Z*Et8=8MyR}bcq7o>60fd_fU3d+F zodSm9MR)|rcsQp#BTsBDB9;RJ;W6My_s;<@)r5f|3f1E&3qx@-z>Q4#5Kx82r_Zuk z0ir2rJXUzMI~IRQAc!CefEfypaVes_+N}3{F$K*U(X@vWhGndGrfLwqM%=8|3==p# zWsL5!K6LzxeMu}Mta_&nkE|#AJ!LS@n*g3s9MzfL#$ixdiP&BBAmRg0|3!Q#d#Lm_ zKHBiMEy&^vI3Di97X2OfDZ=Nu8Wygj% zJUK&}g`+jMrWs`kp~Uu42GKwW^FNZ`L29kN85OCX@~O%6?R5sb-@?2pJ_^>Z7xU`M~agttlbMG5{?+8B=}- z46^>zjy-B3C{W7<1E8-CKxT+6L5i&sW8+Y(?PY)Ju8I1lO#Xi|_l1404}i<&`bsyC zR{c6?C%1Ep^i zv0Tk5R}{d-2Vw{@sy`<$rt)iPLwyuyp2&@iLp#MvOMU79lZ`-stCuxpvN?uxp8YieA~-ZeQ!Z-v)9H`2T;&Z`muqmYbw*$6$@kc^6MB+z(kCEblnl z`YR=E4xtNgwCKCwW(ie0Jpn4;h#_6Ac8=_o0U{qZakYO& zCYyoHjQ-93?XTZiC{#CF@}gP~<*pd|Si{7J@manr<05m4ImpZcNwV+DV$D#U@kv%e z?iqgLxdx2?-oN$XZzNR0z&sqN&JAZy6l7MA>@mgWprmR?_p(_>d9z(h@UYya>PeK= zAnczf4K;V%CBx$p^(*#rrEEM5J6eDFoQ`{7#PwIl3Wrsfe&4h|3fKlWjiTT9A9_iHq#*ohY^q5%uC$)6VthA&5 z^HQd)r2RLB;;`UJnda|>M6f$8M3`P0C z(7j7kN-iap!n0Jt_~}cPbTKywqLjKc1gta1WRg_{s8t2bTfjk~f8kUHro^Siy~Vi* zb6bHZ*Uu~=VEb7BZIyH8S~bRdKx@OvYtT%cVdl)llVLgo4jX@xwjoo+YATn1IhRie zXl&>mtesMQatS!5@55%41~^^4^yvdUaaNmHati5?$;V@}zpL@?;PFcB;JVdC_8@!WkPgZI(D13o^3)<)XA#fwb*TAnVF(RF( zsWN+WX&V~;L0+1Y=2n?$-ITqu?+b!?N+Eh$vwdBqbT)ygLd0sShnXLG>VM{^S5I%1 z&PNJL--av~z&5!q&xO?l>|eToM`cmO^LT?<2NrKaWO>B8yTM+jyNI5+=L`2BKOxr1jEm?NLpCS+kW7F)Z2R5D#dR7;23 zj9&rR$xL9)PymWP#n7d=CS6SV%uuULfy$EMw1BnYGPB98bBeX$e^`7RD!Zb}IamV% zFPqV(TE2fYv?N2}p<13wX1*tfnQgp3ZbNy4C3HczjFhdi5wv!Z=7J{w-kUby);Wfh*)5wNUW%hvA z`Sr%Pe_!mg>KjUjfk*)thijmZn}cz~m@t4Q4(xvrHX`&*uu)}MzS-FS$l;ayxm(0>>mJ#_^^edOy2>`vwg_WK3#dMi5H5j6ptC=BMlD3^Iw*iVMEq5_q5;< zfNm;i)vvJvFxgGom0`D#7ce9hW00000NkvXXu0mjf D<(WqE diff --git a/src/assets/gfx/character_select/characters.png b/src/assets/gfx/character_select/characters.png new file mode 100644 index 0000000000000000000000000000000000000000..8602f0c6b4795d939704bf6761f53d36ea8e0883 GIT binary patch literal 42252 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%0DU$ox;V{BArE-KL7Km-#F$=C+CvLXa`@`oV!bHK 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 BT*d$Z literal 0 HcmV?d00001 diff --git a/src/assets/gfx/character_select/characters.png.import b/src/assets/gfx/character_select/characters.png.import new file mode 100644 index 0000000..16094df --- /dev/null +++ b/src/assets/gfx/character_select/characters.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cj3adlbm2ksl4" +path="res://.godot/imported/characters.png-553a9ff6a8c9c16b8c48788bb39c11f3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/character_select/characters.png" +dest_files=["res://.godot/imported/characters.png-553a9ff6a8c9c16b8c48788bb39c11f3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/character_select/characters2.png b/src/assets/gfx/character_select/characters2.png new file mode 100644 index 0000000000000000000000000000000000000000..0b7108158edf62d589986629c9493457d65dc7bd GIT binary patch literal 16749 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 DrZ3dh literal 0 HcmV?d00001 diff --git a/src/assets/gfx/character_select/characters2.png.import b/src/assets/gfx/character_select/characters2.png.import new file mode 100644 index 0000000..8e753d0 --- /dev/null +++ b/src/assets/gfx/character_select/characters2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dp8hmks54a5pn" +path="res://.godot/imported/characters2.png-83b775982bc6db3ebbc0de7f992fcc55.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/character_select/characters2.png" +dest_files=["res://.godot/imported/characters2.png-83b775982bc6db3ebbc0de7f992fcc55.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/character_select/dwarf.png b/src/assets/gfx/character_select/dwarf.png new file mode 100644 index 0000000000000000000000000000000000000000..dbcd95f13c8563fef6de619e48db8b52d2a2e6da GIT binary patch literal 12558 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 YPeVFttQ7^pyL>=HRY#>j*)sJ10Gjqr5dZ)H literal 0 HcmV?d00001 diff --git a/src/assets/gfx/character_select/elf.png.import b/src/assets/gfx/character_select/elf.png.import new file mode 100644 index 0000000..e41f4cf --- /dev/null +++ b/src/assets/gfx/character_select/elf.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3r1hyhgtv7sf" +path="res://.godot/imported/elf.png-d51237727e8d3e47b74708b9daddac02.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/character_select/elf.png" +dest_files=["res://.godot/imported/elf.png-d51237727e8d3e47b74708b9daddac02.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/character_select/wizard.png b/src/assets/gfx/character_select/wizard.png new file mode 100644 index 0000000000000000000000000000000000000000..0fad327fbd1fc3443a6e9d481a8d06fdfd352182 GIT binary patch literal 16169 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 literal 0 HcmV?d00001 diff --git a/src/assets/gfx/character_select/wizard.png.import b/src/assets/gfx/character_select/wizard.png.import new file mode 100644 index 0000000..86d57b3 --- /dev/null +++ b/src/assets/gfx/character_select/wizard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3qv0erfu3xtl" +path="res://.godot/imported/wizard.png-88804da8863366e120bc2fc50367834e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/character_select/wizard.png" +dest_files=["res://.godot/imported/wizard.png-88804da8863366e120bc2fc50367834e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png b/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png new file mode 100644 index 0000000000000000000000000000000000000000..e61f360ddd8dc52fd8d7eadc8a4b7bf69e5c0827 GIT binary patch literal 31624 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 literal 0 HcmV?d00001 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png.import b/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png.import new file mode 100644 index 0000000..cb081c5 --- /dev/null +++ b/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://du3q527l26q1d" +path="res://.godot/imported/Spiderbat death.png-6a9578b9aaedfddc0a0b3de871bd4fd6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png" +dest_files=["res://.godot/imported/Spiderbat death.png-6a9578b9aaedfddc0a0b3de871bd4fd6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/down_right.png b/src/assets/gfx/enemies/boss/SpiderBat/down_right.png new file mode 100644 index 0000000000000000000000000000000000000000..4051c38b4c40009f45800e7085cbbe433d6b1f4a GIT binary patch literal 18436 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 literal 0 HcmV?d00001 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/down_right.png.import b/src/assets/gfx/enemies/boss/SpiderBat/down_right.png.import new file mode 100644 index 0000000..4390d46 --- /dev/null +++ b/src/assets/gfx/enemies/boss/SpiderBat/down_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr5mc326wpob3" +path="res://.godot/imported/down_right.png-6d523ff59778709e04a9a3ea67a6b897.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/enemies/boss/SpiderBat/down_right.png" +dest_files=["res://.godot/imported/down_right.png-6d523ff59778709e04a9a3ea67a6b897.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png b/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png new file mode 100644 index 0000000000000000000000000000000000000000..bfd4320b2a875ff9608fc23e37c340143426a001 GIT binary patch literal 36682 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 F`Y+LqsrLW? literal 0 HcmV?d00001 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png.import b/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png.import new file mode 100644 index 0000000..dd9cda0 --- /dev/null +++ b/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://fevkanam8r2s" +path="res://.godot/imported/flying_down.png-376108b3e3498c8a5a7b1f340a520add.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/enemies/boss/SpiderBat/flying_down.png" +dest_files=["res://.godot/imported/flying_down.png-376108b3e3498c8a5a7b1f340a520add.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/enemies/boss/SpiderBat/right.png b/src/assets/gfx/enemies/boss/SpiderBat/right.png new file mode 100644 index 0000000000000000000000000000000000000000..7032968c3ec85d99373885d8185253f8b63a87c5 GIT binary patch literal 16101 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 void: #var scale_factor = 0.28 + abs(velocity.x) / velocity_magnitude # Adjust the factor to your preference # Apply the scaling to the shadow - shadow.rotation = -(angle - PI / 2) + shadow.rotation = - (angle - PI / 2) func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = null, charge_percentage: float = 1.0) -> void: direction = shoot_direction.normalized() @@ -78,7 +80,7 @@ func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = nu # Speed: min 120, max 320, scales with charge % (0.5 to 1.0) var min_speed = 120.0 var max_speed = 320.0 - speed = lerp(min_speed, max_speed, (charge_percentage - 0.5) * 2.0) # Map 0.5-1.0 to 0.0-1.0 range + speed = lerp(min_speed, max_speed, (charge_percentage - 0.5) * 2.0) # Map 0.5-1.0 to 0.0-1.0 range # Flight duration: 50% charge = 0.5s, 100% charge = 2.5s max_flight_duration = lerp(0.5, 2.5, (charge_percentage - 0.5) * 2.0) @@ -91,12 +93,10 @@ func _process(delta: float) -> void: # Track flight time and "land" arrow after max_flight_duration flight_timer += delta if flight_timer >= max_flight_duration: - # Arrow has flown for max duration - "land" it (stop and stick to ground) - can_deal_damage = false - $SfxLandsOnGround.play() - _stick_to_wall() # Land on ground - print("Arrow landed after flying for ", flight_timer, " seconds") - return # Exit early to prevent further movement this frame + # Defer landing so any body_entered from this frame's physics runs first. + # Otherwise we can set can_deal_damage=false before the overlap is processed. + call_deferred("_land_arrow_from_flight") + return # Exit early to prevent further movement this frame # Continue flying velocity = direction * speed @@ -115,8 +115,14 @@ func _process(delta: float) -> void: if stick_timer >= others_collection_delay and not can_be_collected: can_be_collected = true - # Use appropriate duration based on what it's stuck to - var duration = wall_stick_duration if stuck_to_wall else stick_duration + # Use appropriate duration based on what it's stuck to (enemy = faster fade) + var duration: float + if stuck_to_wall: + duration = wall_stick_duration + elif stuck_to_enemy: + duration = stick_duration_enemy + else: + duration = stick_duration if stick_timer >= duration: # Start fading out after it sticks @@ -157,29 +163,9 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: if body in hit_targets: return - # Deal damage to players + # Ignore other players - arrow passes through (no friendly fire) if body.is_in_group("player") and body.has_method("rpc_take_damage"): - # Add to hit_targets to prevent multiple hits on this target hit_targets[body] = true - - play_impact() - - # CRITICAL: Stick to target on ALL clients FIRST (before damage check) - # This ensures the arrow stops on all clients, not just the authority - _stick_to_target(body) - - # CRITICAL: Only the projectile owner (authority) should deal damage to players - if player_owner and player_owner.is_multiplayer_authority(): - var attacker_pos = player_owner.global_position if player_owner else global_position - var player_peer_id = body.get_multiplayer_authority() - if player_peer_id != 0: - if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): - body.rpc_take_damage(20.0, attacker_pos) # TODO: Get actual damage from player - else: - body.rpc_take_damage.rpc_id(player_peer_id, 20.0, attacker_pos) - else: - body.rpc_take_damage.rpc(20.0, attacker_pos) - return # Deal damage to enemies @@ -187,10 +173,10 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: # CRITICAL: Only the authority should process enemy collisions # This ensures hit/miss/dodge calculations happen once and are consistent if player_owner and not player_owner.is_multiplayer_authority(): - return # Non-authority ignores enemy collisions + return # Non-authority ignores enemy collisions var attacker_pos = player_owner.global_position if player_owner else global_position - var damage = 20.0 # TODO: Get actual damage from player + var damage = 20.0 # TODO: Get actual damage from player if player_owner and player_owner.character_stats: damage = player_owner.character_stats.damage @@ -204,14 +190,11 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: if is_miss: # MISS - arrow passes through enemy and continues flying! if body.has_method("_show_damage_number"): - body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true - # Add to hit_targets so we don't check this enemy again + body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true hit_targets[body] = true # Sync miss to all clients - arrow continues flying - # CRITICAL: Validate body is still valid and use name instead of path - if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): - _sync_arrow_miss.rpc(body.name) - # Don't stick to target - let arrow continue flying + if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree(): + _sync_arrow_miss_via_gameworld(name, body.name) return # Check enemy dodge chance (based on enemy's DEX stat) @@ -224,14 +207,11 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: if is_dodge: # DODGE - arrow passes through enemy and continues flying! if body.has_method("_show_damage_number"): - body._show_damage_number(0.0, attacker_pos, false, false, true) # is_dodged = true - # Add to hit_targets so we don't check this enemy again + body._show_damage_number(0.0, attacker_pos, false, false, true) # is_dodged = true hit_targets[body] = true # Sync dodge to all clients - arrow continues flying - # CRITICAL: Validate body is still valid and use name instead of path - if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): - _sync_arrow_dodge.rpc(body.name) - # Don't stick to target - let arrow continue flying + if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree(): + _sync_arrow_dodge_via_gameworld(name, body.name) print(body.name, " DODGED arrow! Arrow continues flying...") return @@ -250,26 +230,39 @@ func _on_arrow_area_body_entered(body: Node2D) -> void: body.rpc_take_damage.rpc(damage, attacker_pos, false) # Sync hit to all clients - arrow sticks - # CRITICAL: Validate body is still valid and use name instead of path - if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree(): - _sync_arrow_hit.rpc(body.name) + # CRITICAL: Route through game_world to avoid node path issues + if is_inside_tree() and is_instance_valid(self) and is_instance_valid(body) and body.is_inside_tree(): + var arrow_name = name + _sync_arrow_hit_via_gameworld(arrow_name, body.name) - _stick_to_target(body) + # Arrow hit effect (sync so all clients see it) + var hit_pos = body.global_position + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_sync_arrow_hit_effect"): + if multiplayer.is_server(): + gw._sync_arrow_hit_effect(hit_pos.x, hit_pos.y) + if gw.has_method("_rpc_to_ready_peers"): + gw._rpc_to_ready_peers("_sync_arrow_hit_effect", [hit_pos.x, hit_pos.y]) + else: + gw._request_arrow_hit_effect.rpc_id(1, hit_pos.x, hit_pos.y) + + _stick_to_target(body, true) return # Hit wall or other object $SfxImpactWall.play() _stick_to_wall() -func _stick_to_target(target: Node2D): +func _stick_to_target(target: Node2D, to_enemy: bool = false): # Stop the arrow velocity = Vector2.ZERO is_stuck = true + stuck_to_enemy = to_enemy stick_timer = 0.0 arrow_area.set_deferred("monitoring", false) # Calculate the collision point - move arrow slightly back from its direction - var collision_normal = -direction + var collision_normal = - direction var offset_distance = 8 var stick_position = global_position + (collision_normal * offset_distance) @@ -280,6 +273,15 @@ func _stick_to_target(target: Node2D): self.set_deferred("global_position", stick_position) self.set_deferred("global_rotation", global_rot) +func _land_arrow_from_flight(): + # Called deferred when flight_timer expired - ensures body_entered from this frame ran first + if is_stuck: + return + can_deal_damage = false + $SfxLandsOnGround.play() + _stick_to_wall() + print("Arrow landed after flying for ", flight_timer, " seconds") + func _stick_to_wall(): # Stop the arrow velocity = Vector2.ZERO @@ -297,69 +299,43 @@ func _sync_arrow_collected_via_gameworld(arrow_name: String): if gw and gw.has_method("_sync_arrow_collected") and multiplayer.has_multiplayer_peer(): gw._sync_arrow_collected.rpc(arrow_name) -@rpc("any_peer", "call_local", "reliable") -func _sync_arrow_hit(target_name: String): - # Authority determined arrow HIT enemy - stick to it on all clients - # CRITICAL: Validate arrow is still valid before processing - if not is_instance_valid(self) or not is_inside_tree(): +func _sync_arrow_hit_via_gameworld(arrow_name: String, target_name: String): + # Route arrow hit sync through game_world to avoid node path issues + if arrow_name.is_empty() or target_name.is_empty(): return - - # Find target by name in Entities node - var target = null - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - var entities_node = game_world.get_node_or_null("Entities") - if entities_node: - target = entities_node.get_node_or_null(target_name) - - if not target: - print("WARNING: Arrow hit target not found: ", target_name) + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_sync_arrow_hit") and multiplayer.has_multiplayer_peer(): + gw._sync_arrow_hit.rpc(arrow_name, target_name) + +func _sync_arrow_miss_via_gameworld(arrow_name: String, target_name: String): + # Route arrow miss sync through game_world to avoid node path issues + if arrow_name.is_empty() or target_name.is_empty(): + return + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_sync_arrow_miss") and multiplayer.has_multiplayer_peer(): + gw._sync_arrow_miss.rpc(arrow_name, target_name) + +func _sync_arrow_dodge_via_gameworld(arrow_name: String, target_name: String): + # Route arrow dodge sync through game_world to avoid node path issues + if arrow_name.is_empty() or target_name.is_empty(): + return + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_sync_arrow_dodge") and multiplayer.has_multiplayer_peer(): + gw._sync_arrow_dodge.rpc(arrow_name, target_name) + +# Helper method for game_world to process arrow hit sync +func _process_arrow_hit_sync(target: Node): + # Process arrow hit sync from game_world + if not is_instance_valid(self) or not is_inside_tree(): return if target not in hit_targets: hit_targets[target] = true play_impact() - _stick_to_target(target) + var is_enemy = target.is_in_group("enemy") if target else false + _stick_to_target(target, is_enemy) print("Arrow synced as HIT to: ", target.name) -@rpc("any_peer", "call_local", "reliable") -func _sync_arrow_miss(target_name: String): - # Authority determined arrow MISSED enemy - continues flying on all clients - # CRITICAL: Validate arrow is still valid before processing - if not is_instance_valid(self) or not is_inside_tree(): - return - - # Find target by name in Entities node - var target = null - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - var entities_node = game_world.get_node_or_null("Entities") - if entities_node: - target = entities_node.get_node_or_null(target_name) - - if target and target not in hit_targets: - hit_targets[target] = true - print("Arrow synced as MISS - continuing through: ", target.name if target else "unknown") - -@rpc("any_peer", "call_local", "reliable") -func _sync_arrow_dodge(target_name: String): - # Authority determined enemy DODGED arrow - continues flying on all clients - # CRITICAL: Validate arrow is still valid before processing - if not is_instance_valid(self) or not is_inside_tree(): - return - - # Find target by name in Entities node - var target = null - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - var entities_node = game_world.get_node_or_null("Entities") - if entities_node: - target = entities_node.get_node_or_null(target_name) - - if target and target not in hit_targets: - hit_targets[target] = true - print("Arrow synced as DODGE - continuing through: ", target.name if target else "unknown") - func _enable_collection_area(): # Create an Area2D for collecting the arrow if collection_area: diff --git a/src/scripts/attack_axe_swing.gd b/src/scripts/attack_axe_swing.gd index f8c4caf..9cf21dc 100644 --- a/src/scripts/attack_axe_swing.gd +++ b/src/scripts/attack_axe_swing.gd @@ -1,64 +1,157 @@ extends Node2D -var direction := Vector2.ZERO # Default direction -var fade_delay := 0.14 # When to start fading (mid-move) -var move_duration := 0.2 # Slash exists for 0.3 seconds -var fade_duration := 0.06 # Time to fade out -var stretch_amount := Vector2(1, 1.4) # How much to stretch the sprite -var slash_amount = 8 -var initiated_by: Node2D = null +# Axe Swing - stays on player, plays $SwingAnimation by direction (attack_down, attack_right, etc.). +# Uses equipped axe texture/frame. On hit: deal damage, spawn damage_effect_axe. +# Duration ~0.27s to match player AXE/SWORD animation. + +const LIFETIME: float = 0.27 +const DIR_ANIMATIONS: Array = [ + "attack_right", # 0: right + "attack_down_right", # 1 + "attack_down", # 2 + "attack_down_left", # 3 + "attack_left", # 4 + "attack_up_left", # 5 + "attack_up", # 6 + "attack_up_right" # 7 +] + +@export var damage: float = 20.0 +var elapsed_time: float = 0.0 +var player_owner: Node = null +var hit_targets: Dictionary = {} + +var damage_effect_axe_scene: PackedScene = preload("res://scenes/damage_effect_axe.tscn") + +@onready var sprite: Sprite2D = $Sprite2D +@onready var hit_area: Area2D = $DamageArea +@onready var swing_animation: AnimationPlayer = $SwingAnimation -# Called when the node enters the scene tree for the first time. func _ready() -> void: - call_deferred("_initialize_swing") - pass # Replace with function body. - -func _initialize_swing(): - var tween = create_tween() - var move_target = global_position + (direction.normalized() * slash_amount) # Moves in given direction - tween.set_trans(Tween.TRANS_CUBIC) # Smooth acceleration & deceleration - tween.set_ease(Tween.EASE_OUT) # Fast start, then slows down - tween.tween_property(self, "global_position", move_target, move_duration) - ' - # Create stretch tween (grow and shrink slightly) - var stretch_tween = create_tween() - stretch_tween.set_trans(Tween.TRANS_CUBIC) - stretch_tween.set_ease(Tween.EASE_OUT) - stretch_tween.tween_property($Sprite2D, "scale", Vector2.ONE, move_duration / 2) # start normal - stretch_tween.tween_property($Sprite2D, "scale", stretch_amount, move_duration / 2) - ' - - # Wait until mid-move to start fade - await get_tree().create_timer(fade_delay).timeout - - # Start fade-out effect - var fade_tween = create_tween() - fade_tween.tween_property($Sprite2D, "modulate:a", 0.0, fade_duration) # Fade to transparent - await fade_tween.finished - queue_free() - pass + if hit_area: + if not hit_area.body_entered.is_connected(_on_damage_area_body_entered): + hit_area.body_entered.connect(_on_damage_area_body_entered) + if has_node("AttackSwosh"): + $AttackSwosh.play() + +func setup(attack_direction: Vector2, owner_player: Node, _arc_direction: float = 1.0, axe_item: Item = null) -> void: + player_owner = owner_player + if owner_player and owner_player.character_stats: + damage = owner_player.character_stats.damage + # Use equipped axe texture and frame + if axe_item and sprite: + var tex = load(axe_item.spritePath) as Texture2D + if tex: + sprite.texture = tex + sprite.hframes = axe_item.spriteFrames.x if axe_item.spriteFrames.x > 0 else 20 + sprite.vframes = axe_item.spriteFrames.y if axe_item.spriteFrames.y > 0 else 14 + sprite.frame = axe_item.spriteFrame + # Pick direction animation: 8 sectors by angle + var dir_norm = attack_direction.normalized() + var angle = dir_norm.angle() + var sector = int(round(angle / (TAU / 8.0))) % 8 + if sector < 0: + sector += 8 + var anim_name = DIR_ANIMATIONS[sector] if sector < DIR_ANIMATIONS.size() else "attack_down" + if swing_animation and swing_animation.has_animation(anim_name): + swing_animation.play(anim_name) + +func _process(delta: float) -> void: + elapsed_time += delta + if player_owner and is_instance_valid(player_owner): + global_position = player_owner.global_position + if elapsed_time >= LIFETIME: + queue_free() func _on_damage_area_body_entered(body: Node2D) -> void: - if body.get_parent() == initiated_by or body == initiated_by: + if body == player_owner: return - if body.get_parent() is CharacterBody2D and body.get_parent().stats.is_invulnerable == false and body.get_parent().stats.hp > 0: # hit an enemy - $MeleeImpact.play() - body.take_damage(self, initiated_by) - pass - else: - $MeleeImpactWall.play() - pass - pass # Replace with function body. - - -func _on_damage_area_area_entered(body: Area2D) -> void: - if body.get_parent() == initiated_by: + if body in hit_targets: return - if body.get_parent() is CharacterBody2D and body.get_parent().stats.is_invulnerable == false and body.get_parent().stats.hp > 0: # hit an enemy - $MeleeImpact.play() - body.get_parent().take_damage(self, initiated_by) - pass - else: + hit_targets[body] = true + + # Only authority deals damage + if player_owner and not player_owner.is_multiplayer_authority(): + return + + var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position + + # Ignore other players + if body.is_in_group("player") and body.has_method("take_damage"): + return + + # Enemy: deal damage via game_world, spawn axe hit effect + if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + var game_world = get_tree().get_first_node_in_group("game_world") + var enemy_name = body.name + var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1 + if game_world and game_world.has_method("_request_enemy_damage"): + if multiplayer.is_server(): + game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false) + else: + game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false) + else: + var eid = body.get_multiplayer_authority() + if eid != 0: + if multiplayer.is_server() and eid == multiplayer.get_unique_id(): + body.rpc_take_damage(damage, attacker_pos, false) + else: + body.rpc_take_damage.rpc_id(eid, damage, attacker_pos, false) + else: + body.rpc_take_damage.rpc(damage, attacker_pos, false) + if has_node("MeleeImpact"): + $MeleeImpact.play() + _spawn_axe_hit_effect(body.global_position) + return + + # Boxes / interactables with health + if "health" in body: + if has_node("MeleeImpact"): + $MeleeImpact.play() + body.health -= damage + if body.health <= 0 and body.has_method("_break_into_pieces"): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and multiplayer.has_multiplayer_peer(): + var obj_name = body.name + if body.has_meta("object_index"): + var idx = body.get_meta("object_index") + if idx >= 0: + obj_name = "InteractableObject_%d" % idx + if multiplayer.is_server(): + game_world._rpc_to_ready_peers("_sync_object_break", [obj_name]) + else: + game_world._sync_object_break.rpc_id(1, obj_name) + body._break_into_pieces() + _spawn_axe_hit_effect(body.global_position) + return + + if has_node("MeleeImpactWall"): $MeleeImpactWall.play() - pass - pass # Replace with function body. + + # Knockback for CharacterBody2D (e.g. corpses) + if body is CharacterBody2D: + var knockback_dir = (body.global_position - global_position).normalized() + body.velocity = knockback_dir * 200.0 + +func _spawn_axe_hit_effect(at_position: Vector2) -> void: + if multiplayer.has_multiplayer_peer() and player_owner and player_owner.is_multiplayer_authority(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_axe_hit_effect"): + if multiplayer.is_server(): + game_world._sync_axe_hit_effect(at_position.x, at_position.y) + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_axe_hit_effect", [at_position.x, at_position.y]) + else: + game_world._request_axe_hit_effect.rpc_id(1, at_position.x, at_position.y) + return + if not damage_effect_axe_scene: + return + var effect = damage_effect_axe_scene.instantiate() + var parent = get_parent() + if parent: + parent.add_child(effect) + else: + get_tree().current_scene.add_child(effect) + effect.global_position = at_position + if effect.has_method("setup"): + effect.setup(at_position) diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd index 90a4933..859bfd5 100644 --- a/src/scripts/attack_bomb.gd +++ b/src/scripts/attack_bomb.gd @@ -434,6 +434,15 @@ func _spawn_explosion_tile_particles(): spr.texture = tex spr.region_enabled = true spr.region_rect = regions[i] + + # CRITICAL: Apply level's material and colorization to tile particles + # Get the material from the tilemap layer and duplicate it + # Duplicating ShaderMaterial copies all shader parameters (colorization, tint, ambient, etc.) + if layer.material and layer.material is ShaderMaterial: + var layer_material = layer.material as ShaderMaterial + var particle_material = layer_material.duplicate() as ShaderMaterial + spr.material = particle_material + p.global_position = world var speed = randf_range(280.0, 420.0) # Much faster - fly around more var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) diff --git a/src/scripts/attack_punch.gd b/src/scripts/attack_punch.gd new file mode 100644 index 0000000..64c0a76 --- /dev/null +++ b/src/scripts/attack_punch.gd @@ -0,0 +1,135 @@ +extends Node2D + +# Unarmed punch - appears in front of player, low STR-based damage. +# Uses shade_spell_effects.png frames 108-113 for the punch, spawns damage_effect_punch on hit. + +@export var damage: float = 3.0 +@export var lifetime: float = 0.25 +@export var punch_distance: float = 12.0 # Closer than sword (less range) + +const PUNCH_FRAMES: Array = [108, 109, 110, 111, 112, 113] +const FRAME_DURATION: float = 0.04 + +var punch_direction: Vector2 = Vector2.RIGHT +var player_owner: Node = null +var hit_targets: Dictionary = {} +var elapsed: float = 0.0 + +var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn") + +@onready var sprite: Sprite2D = $Sprite2D +@onready var hit_area: Area2D = $Area2D + +func _ready() -> void: + if has_node("SfxSwosh"): + $SfxSwosh.play() + if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): + hit_area.body_entered.connect(_on_body_entered) + if sprite and PUNCH_FRAMES.size() > 0: + sprite.frame = PUNCH_FRAMES[0] + +func setup(direction: Vector2, owner_player: Node, damage_value: float = 3.0) -> void: + punch_direction = direction.normalized() + player_owner = owner_player + damage = damage_value + rotation = punch_direction.angle() + # Position in front of player + if owner_player and is_instance_valid(owner_player): + global_position = owner_player.global_position + punch_direction * punch_distance + +func _process(delta: float) -> void: + elapsed += delta + # Keep punch in front of player + if player_owner and is_instance_valid(player_owner): + global_position = player_owner.global_position + punch_direction * punch_distance + # Animate frames 108-113 + if sprite and PUNCH_FRAMES.size() > 0: + var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1) + sprite.frame = PUNCH_FRAMES[frame_idx] + if elapsed >= lifetime: + hit_area.set_deferred("monitoring", false) + queue_free() + +func _on_body_entered(body: Node2D) -> void: + if body == player_owner: + return + if body in hit_targets: + return + hit_targets[body] = true + + # Only authority deals damage (same as sword_projectile) + if player_owner and not player_owner.is_multiplayer_authority(): + return + + var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position + + # Ignore other players (no friendly fire) - pass through + if body.is_in_group("player") and body.has_method("rpc_take_damage"): + return + + # Enemy: apply damage (enemy's take_damage/rpc_take_damage applies defence) + if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + var game_world = get_tree().get_first_node_in_group("game_world") + var enemy_name = body.name + var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1 + if game_world and game_world.has_method("_request_enemy_damage"): + if multiplayer.is_server(): + game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false) + else: + game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false) + else: + var enemy_peer_id = body.get_multiplayer_authority() + if enemy_peer_id != 0: + if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): + body.rpc_take_damage(damage, attacker_pos, false) + else: + body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, false) + else: + body.rpc_take_damage.rpc(damage, attacker_pos, false) + if has_node("SfxImpact"): + $SfxImpact.play() + _spawn_damage_effect(body.global_position) + return + + # Interactables with health (boxes, etc.) - small damage + if "health" in body: + if has_node("SfxImpact"): + $SfxImpact.play() + body.health -= damage + if body.health <= 0 and body.has_method("_break_into_pieces"): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and multiplayer.has_multiplayer_peer(): + var obj_name = body.name + if body.has_meta("object_index"): + var idx = body.get_meta("object_index") + if idx >= 0: + obj_name = "InteractableObject_%d" % idx + if multiplayer.is_server(): + game_world._rpc_to_ready_peers("_sync_object_break", [obj_name]) + else: + game_world._sync_object_break.rpc_id(1, obj_name) + body._break_into_pieces() + _spawn_damage_effect(body.global_position) + +func _spawn_damage_effect(at_position: Vector2) -> void: + if multiplayer.has_multiplayer_peer() and player_owner and player_owner.is_multiplayer_authority(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_punch_hit_effect"): + if multiplayer.is_server(): + game_world._sync_punch_hit_effect(at_position.x, at_position.y) + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_punch_hit_effect", [at_position.x, at_position.y]) + else: + game_world._request_punch_hit_effect.rpc_id(1, at_position.x, at_position.y) + return + if not damage_effect_punch_scene: + return + var effect = damage_effect_punch_scene.instantiate() + var parent = get_parent() + if parent: + parent.add_child(effect) + else: + get_tree().current_scene.add_child(effect) + effect.global_position = at_position + if effect.has_method("setup"): + effect.setup(at_position) diff --git a/src/scripts/attack_punch.gd.uid b/src/scripts/attack_punch.gd.uid new file mode 100644 index 0000000..7ad89f8 --- /dev/null +++ b/src/scripts/attack_punch.gd.uid @@ -0,0 +1 @@ +uid://ddqd1nlmsb8k6 diff --git a/src/scripts/attack_spell_flame.gd b/src/scripts/attack_spell_flame.gd index 3910888..37da9a1 100644 --- a/src/scripts/attack_spell_flame.gd +++ b/src/scripts/attack_spell_flame.gd @@ -9,6 +9,7 @@ extends Node2D var player_owner: Node = null var hit_targets = {} # Track what we've already hit var first_hit_targets = {} # Track targets that haven't taken initial damage yet +var bodies_in_area = {} # Track bodies currently in the area (for enter/exit detection) var damage_timer: float = 0.0 var animation_timer: float = 0.0 var current_frame: int = 4 # Start at frame 4 (first burning frame) @@ -133,6 +134,7 @@ func _start_sprite_animation(): func _deal_periodic_damage(): # Get all bodies in the area var bodies = hit_area.get_overlapping_bodies() + var current_bodies = {} for body in bodies: if body == player_owner: @@ -142,16 +144,22 @@ func _deal_periodic_damage(): if player_owner and not player_owner.is_multiplayer_authority(): continue - # Check if this is the first hit on this target (for initial damage bonus) + current_bodies[body] = true + + # Check if this body just entered the area (not in bodies_in_area) + var just_entered = not (body in bodies_in_area) + + # If body just entered, deal high damage (like initial hit) + # Also deal high damage on first hit ever var is_first_hit = not (body in first_hit_targets) if is_first_hit: first_hit_targets[body] = true - # Calculate damage - initial damage is much higher for first hit + # Calculate damage - high damage when entering area or first hit, regular damage otherwise var final_damage = damage var int_bonus_damage = 0.0 # Declare outside if block for use in print statements - if is_first_hit: - # Initial damage is multiplied and gets INT bonus + if just_entered or is_first_hit: + # High damage when entering area (multiplied and gets INT bonus) final_damage = damage * initial_damage_multiplier if player_owner and player_owner.character_stats: var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int") @@ -198,25 +206,32 @@ func _deal_periodic_damage(): else: body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, apply_burn) - if is_first_hit: + if just_entered or is_first_hit: var int_bonus = int_bonus_damage if player_owner and player_owner.character_stats else 0.0 - print("Flame spell INITIAL hit enemy: ", body.name, " for ", final_damage, " damage (base: ", damage, " x ", initial_damage_multiplier, " + INT bonus: ", int_bonus, ")") + print("Flame spell HIGH DAMAGE hit enemy: ", body.name, " for ", final_damage, " damage (base: ", damage, " x ", initial_damage_multiplier, " + INT bonus: ", int_bonus, ")") else: print("Flame spell hit enemy: ", body.name, " for ", final_damage, " damage!") - + # Destroy wooden interactable objects (box, barrel, pot, etc.) – they burn and break elif body.is_in_group("interactable_object") and body.has_method("take_fire_damage"): if "is_being_held" in body and body.is_being_held: continue # Don't break objects while held var attacker_pos = player_owner.global_position if player_owner else global_position body.take_fire_damage(final_damage, attacker_pos) - if is_first_hit: + if just_entered or is_first_hit: + print("Flame spell HIGH DAMAGE burning wooden object: ", body.name, " for ", final_damage, " damage!") + else: print("Flame spell burning wooden object: ", body.name, " for ", final_damage, " damage!") + + # Update bodies_in_area for next check + bodies_in_area = current_bodies -func _on_body_entered(_body): - # Track bodies that enter the area (for periodic damage) - # Don't add to hit_targets here - we want to deal damage multiple times - pass +func _on_body_entered(body): + # Track bodies that enter the area - they will get high damage on next periodic check + # The _deal_periodic_damage function will detect they just entered and deal high damage + if body and body != player_owner: + # Mark that this body just entered (will be processed in _deal_periodic_damage) + pass func _on_lifetime_expired(): # Spell expires - fade out and remove diff --git a/src/scripts/attack_spell_frostspike.gd b/src/scripts/attack_spell_frostspike.gd index 608d6cb..bc3bf4b 100644 --- a/src/scripts/attack_spell_frostspike.gd +++ b/src/scripts/attack_spell_frostspike.gd @@ -43,6 +43,9 @@ func _spawn_adjacent_after_delay() -> void: await get_tree().create_timer(0.5).timeout if not is_instance_valid(self): return + # Round 2: play spike SFX when the 4 adjacent spikes appear + if has_node("SfxSpike"): + $SfxSpike.play() var gw = get_tree().get_first_node_in_group("game_world") if not gw or not gw.has_method("_get_adjacent_valid_spell_tile_centers"): _finish_center_spike() @@ -62,12 +65,11 @@ func _spawn_adjacent_after_delay() -> void: await get_tree().create_timer(0.25).timeout if not is_instance_valid(self): return - # Third wave: center again, 2x scale, 2x damage (most damage) — only after 4 finish + # Third wave: center again, 2x scale, 2x damage (most damage). Use skip_sfx=false so the + # big spike plays SfxSpike in its _ready() — we can't play on self here because we're about to queue_free(). var third = scene.instantiate() - third.setup(global_position, player_owner, damage, false, 2.0, 2.0, true) + third.setup(global_position, player_owner, damage, false, 2.0, 2.0, false) par.add_child(third) - if has_node("SfxSpike"): - $SfxSpike.play() _finish_center_spike() func _finish_center_spike() -> void: diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index f8aa17d..5951b91 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -287,6 +287,10 @@ func modify_health(amount: float, allow_overheal: bool = false) -> void: hp = max(0.0, hp) else: hp = clamp(hp, 0.0, maxhp) + # Fix floating point precision: if HP is very close to 0 (within epsilon), set it to exactly 0.0 + # This ensures death checks work correctly when HP reaches exactly 0 + if hp <= 0.001: + hp = 0.0 health_changed.emit(hp, maxhp) character_changed.emit(self) @@ -321,7 +325,9 @@ func take_damage(amount: float, is_magical: bool = false) -> float: # Calculate damage after DEF reduction var actual_damage = calculate_damage(amount, is_magical) modify_health(-actual_damage) - if hp <= 0: + # Check if dead (use epsilon to handle floating point precision) + if hp <= 0.001: + hp = 0.0 # Ensure exactly 0 no_health.emit() # Emit when health reaches 0 character_changed.emit(self) return actual_damage diff --git a/src/scripts/damage_effect_arrow.gd b/src/scripts/damage_effect_arrow.gd new file mode 100644 index 0000000..5606804 --- /dev/null +++ b/src/scripts/damage_effect_arrow.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Arrow hit effect when arrow damages enemy - frames 335-339 from shade_spell_effects.png + +const DURATION: float = 0.2 +const FRAMES: Array = [335, 336, 337, 338, 339] +const FRAME_DURATION: float = 0.04 + +var elapsed: float = 0.0 + +@onready var fx_sprite: Sprite2D = $FxSprite + +func _ready() -> void: + if fx_sprite and FRAMES.size() > 0: + fx_sprite.frame = FRAMES[0] + +func setup(_position: Vector2 = Vector2.ZERO) -> void: + if _position != Vector2.ZERO: + global_position = _position + +func _process(delta: float) -> void: + elapsed += delta + if fx_sprite and FRAMES.size() > 0: + var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1) + fx_sprite.frame = FRAMES[idx] + if elapsed >= DURATION: + queue_free() diff --git a/src/scripts/damage_effect_arrow.gd.uid b/src/scripts/damage_effect_arrow.gd.uid new file mode 100644 index 0000000..e8787e7 --- /dev/null +++ b/src/scripts/damage_effect_arrow.gd.uid @@ -0,0 +1 @@ +uid://j1ypyujarmn6 diff --git a/src/scripts/damage_effect_axe.gd b/src/scripts/damage_effect_axe.gd new file mode 100644 index 0000000..8168b6a --- /dev/null +++ b/src/scripts/damage_effect_axe.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Axe hit effect when axe swing damages target - frames 1158-1162 from shade_spell_effects.png + +const DURATION: float = 0.2 +const FRAMES: Array = [1158, 1159, 1160, 1161, 1162] +const FRAME_DURATION: float = 0.04 + +var elapsed: float = 0.0 + +@onready var fx_sprite: Sprite2D = $FxSprite + +func _ready() -> void: + if fx_sprite and FRAMES.size() > 0: + fx_sprite.frame = FRAMES[0] + +func setup(_position: Vector2 = Vector2.ZERO) -> void: + if _position != Vector2.ZERO: + global_position = _position + +func _process(delta: float) -> void: + elapsed += delta + if fx_sprite and FRAMES.size() > 0: + var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1) + fx_sprite.frame = FRAMES[idx] + if elapsed >= DURATION: + queue_free() diff --git a/src/scripts/damage_effect_axe.gd.uid b/src/scripts/damage_effect_axe.gd.uid new file mode 100644 index 0000000..f84228f --- /dev/null +++ b/src/scripts/damage_effect_axe.gd.uid @@ -0,0 +1 @@ +uid://brg44rb3vy1g2 diff --git a/src/scripts/damage_effect_bite.gd b/src/scripts/damage_effect_bite.gd new file mode 100644 index 0000000..e393097 --- /dev/null +++ b/src/scripts/damage_effect_bite.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Bite hit effect when bat/rat damages player - frames 148-158 from shade_spell_effects.png + +const DURATION: float = 0.44 +const FRAMES: Array = [148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158] +const FRAME_DURATION: float = 0.04 + +var elapsed: float = 0.0 + +@onready var fx_sprite: Sprite2D = $FxSprite + +func _ready() -> void: + if fx_sprite and FRAMES.size() > 0: + fx_sprite.frame = FRAMES[0] + +func setup(_position: Vector2 = Vector2.ZERO) -> void: + if _position != Vector2.ZERO: + global_position = _position + +func _process(delta: float) -> void: + elapsed += delta + if fx_sprite and FRAMES.size() > 0: + var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1) + fx_sprite.frame = FRAMES[idx] + if elapsed >= DURATION: + queue_free() diff --git a/src/scripts/damage_effect_bite.gd.uid b/src/scripts/damage_effect_bite.gd.uid new file mode 100644 index 0000000..f5ba273 --- /dev/null +++ b/src/scripts/damage_effect_bite.gd.uid @@ -0,0 +1 @@ +uid://b3l607m13h12y diff --git a/src/scripts/damage_effect_punch.gd b/src/scripts/damage_effect_punch.gd new file mode 100644 index 0000000..537ee76 --- /dev/null +++ b/src/scripts/damage_effect_punch.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Temporary hit effect for punch - plays frames 451-458 from shade_spell_effects.png + +const DURATION: float = 0.32 +const FRAMES: Array = [451, 452, 453, 454, 455, 456, 457, 458] +const FRAME_DURATION: float = 0.04 + +var elapsed: float = 0.0 + +@onready var fx_sprite: Sprite2D = $FxSprite + +func _ready() -> void: + if fx_sprite and FRAMES.size() > 0: + fx_sprite.frame = FRAMES[0] + +func setup(_position: Vector2 = Vector2.ZERO) -> void: + if _position != Vector2.ZERO: + global_position = _position + +func _process(delta: float) -> void: + elapsed += delta + if fx_sprite and FRAMES.size() > 0: + var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1) + fx_sprite.frame = FRAMES[idx] + if elapsed >= DURATION: + queue_free() diff --git a/src/scripts/damage_effect_punch.gd.uid b/src/scripts/damage_effect_punch.gd.uid new file mode 100644 index 0000000..6f2f591 --- /dev/null +++ b/src/scripts/damage_effect_punch.gd.uid @@ -0,0 +1 @@ +uid://bqq0nj858gglm diff --git a/src/scripts/damage_effect_slash.gd b/src/scripts/damage_effect_slash.gd new file mode 100644 index 0000000..d0e4ae3 --- /dev/null +++ b/src/scripts/damage_effect_slash.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Slash hit effect when sword projectile damages enemy - frames 948-952 from shade_spell_effects.png + +const DURATION: float = 0.2 +const FRAMES: Array = [948, 949, 950, 951, 952] +const FRAME_DURATION: float = 0.04 + +var elapsed: float = 0.0 + +@onready var fx_sprite: Sprite2D = $FxSprite + +func _ready() -> void: + if fx_sprite and FRAMES.size() > 0: + fx_sprite.frame = FRAMES[0] + +func setup(_position: Vector2 = Vector2.ZERO) -> void: + if _position != Vector2.ZERO: + global_position = _position + +func _process(delta: float) -> void: + elapsed += delta + if fx_sprite and FRAMES.size() > 0: + var idx = min(int(elapsed / FRAME_DURATION), FRAMES.size() - 1) + fx_sprite.frame = FRAMES[idx] + if elapsed >= DURATION: + queue_free() diff --git a/src/scripts/damage_effect_slash.gd.uid b/src/scripts/damage_effect_slash.gd.uid new file mode 100644 index 0000000..4f8a01f --- /dev/null +++ b/src/scripts/damage_effect_slash.gd.uid @@ -0,0 +1 @@ +uid://bs77pdmfdbnwb diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index 99ead7d..ec2823b 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -4,17 +4,17 @@ extends CharacterBody2D @export var max_health: float = 50.0 @export var move_speed: float = 80.0 -@export var damage: float = 10.0 # Legacy - use character_stats.damage instead +@export var damage: float = 10.0 # Legacy - use character_stats.damage instead @export var attack_cooldown: float = 1.0 -@export var exp_reward: float = 10.0 # EXP granted when this enemy is defeated +@export var exp_reward: float = 10.0 # EXP granted when this enemy is defeated var current_health: float = 50.0 -var character_stats: CharacterStats # RPG stats system (same as players) +var character_stats: CharacterStats # RPG stats system (same as players) var is_dead: bool = false -@export var is_undead: bool = false # Zombies etc.; healing spell damages them +@export var is_undead: bool = false # Zombies etc.; healing spell damages them var target_player: Node = null var attack_timer: float = 0.0 -var killer_player: Node = null # Track who killed this enemy (for kill credit) +var killer_player: Node = null # Track who killed this enemy (for kill credit) # Knockback var is_knocked_back: bool = false @@ -23,11 +23,11 @@ var knockback_duration: float = 0.3 var knockback_force: float = 125.0 # Scaled down for 1x scale # Burn debuff -var burn_debuff_timer: float = 0.0 # Timer for burn debuff -var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds -var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second -var burn_debuff_visual: Node2D = null # Visual indicator for burn debuff -var burn_damage_timer: float = 0.0 # Timer for burn damage ticks +var burn_debuff_timer: float = 0.0 # Timer for burn debuff +var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds +var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second +var burn_debuff_visual: Node2D = null # Visual indicator for burn debuff +var burn_damage_timer: float = 0.0 # Timer for burn damage ticks # Z-axis for flying enemies var position_z: float = 0.0 @@ -109,7 +109,7 @@ func _physics_process(delta): var burn_sprite = burn_debuff_visual as Sprite2D var anim_timer = burn_sprite.get_meta("burn_animation_timer", 0.0) anim_timer += delta - if anim_timer >= 0.1: # ~10 FPS + if anim_timer >= 0.1: # ~10 FPS anim_timer = 0.0 var frame = burn_sprite.get_meta("burn_animation_frame", 0) frame = (frame + 1) % 16 @@ -164,7 +164,10 @@ func _physics_process(delta): var actual_damage = old_hp - character_stats.hp LogManager.log(str(name) + " takes " + str(actual_damage) + " burn damage! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY) # Show damage number for burn damage - _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number + # Use a position slightly offset from global_position to ensure proper direction calculation + # (if from_position equals global_position, direction becomes Vector2.ZERO which causes issues) + var burn_damage_source_pos = global_position + Vector2(randf_range(-10, 10), -20) # Slight random offset above enemy + _show_damage_number(actual_damage, burn_damage_source_pos, false, false, false) # Show burn damage number # Sync burn damage visual to clients if multiplayer.has_multiplayer_peer() and is_inside_tree(): var enemy_name = name @@ -179,7 +182,7 @@ func _physics_process(delta): var burn_sprite = burn_debuff_visual as Sprite2D var anim_timer = burn_sprite.get_meta("burn_animation_timer", 0.0) anim_timer += delta - if anim_timer >= 0.1: # ~10 FPS + if anim_timer >= 0.1: # ~10 FPS anim_timer = 0.0 var frame = burn_sprite.get_meta("burn_animation_frame", 0) frame = (frame + 1) % 16 @@ -303,6 +306,14 @@ func _attack_player(player): player.rpc_take_damage.rpc(damage, global_position) attack_timer = attack_cooldown LogManager.log(str(name) + " attacked " + str(player.name) + " (peer: " + str(player_peer_id) + ", server: " + str(multiplayer.get_unique_id()) + ")", LogManager.CATEGORY_ENEMY) + # Bite effect when bat or rat hits player + var script_path = get_script().resource_path if get_script() else "" + if "enemy_bat" in script_path or "enemy_rat" in script_path: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_bite_effect"): + game_world._sync_bite_effect(player.name) + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_bite_effect", [player.name]) func _find_nearest_player() -> Node: var players = get_tree().get_nodes_in_group("player") @@ -364,7 +375,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals # This allows us to credit kills correctly var nearest_player = _find_nearest_player_to_position(from_position) if nearest_player: - killer_player = nearest_player # Update killer to the most recent attacker + killer_player = nearest_player # Update killer to the most recent attacker # Check for dodge chance (based on DEX) - same as players var _was_dodged = false @@ -376,7 +387,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals var dex_total = character_stats.baseStats.dex + character_stats.get_pass("dex") LogManager.log(str(name) + " DODGED the attack! (DEX: " + str(dex_total) + ", dodge chance: " + str(dodge_chance * 100.0) + "%)", LogManager.CATEGORY_ENEMY) # Show "DODGED" text - _show_damage_number(0.0, from_position, false, false, true) # is_dodged = true + _show_damage_number(0.0, from_position, false, false, true) # is_dodged = true # Sync dodge visual to clients (pass damage_amount=0.0 and is_dodged=true to indicate dodge) if multiplayer.has_multiplayer_peer() and is_inside_tree(): var enemy_name = name @@ -384,13 +395,13 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_enemy_damage_visual"): game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, 0.0, from_position, false, true]) - return # No damage taken, exit early + return # No damage taken, exit early # If not dodged, apply damage with DEF reduction var actual_damage = amount if character_stats: # Calculate damage after DEF reduction (critical hits pierce 80% of DEF) - actual_damage = character_stats.calculate_damage(amount, false, is_critical) # false = not magical, is_critical = crit pierce + actual_damage = character_stats.calculate_damage(amount, false, is_critical) # false = not magical, is_critical = crit pierce character_stats.modify_health(-actual_damage) current_health = character_stats.hp if character_stats.hp <= 0: @@ -439,7 +450,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals if current_health <= 0: # Prevent multiple death triggers if is_dead: - return # Already dying + return # Already dying # Don't set is_dead here - let _die() set it to avoid early return bug # Mark as dead in _die() function instead of here @@ -471,7 +482,6 @@ func rpc_heal_enemy(amount: float, allow_overheal: bool = false): func _show_damage_number(amount: float, from_position: Vector2, is_critical: bool = false, is_miss: bool = false, is_dodged: bool = false): # Show damage number (red/orange for crits, cyan for dodge, gray for miss, using dmg_numbers.png font) above enemy # Show even if amount is 0 for MISS/DODGED - var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return @@ -489,7 +499,7 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo damage_label.color = Color.GRAY else: damage_label.label = str(int(amount)) - damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red + damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red damage_label.z_index = 5 # Calculate direction from attacker (slight upward variation) @@ -512,6 +522,38 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) +func _show_exp_number(amount: float, exp_pos: Vector2): + # Show EXP number (green/yellow, using dmg_numbers.png font) at position + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene: + return + + var exp_label = damage_number_scene.instantiate() + if not exp_label: + return + + # Set text and color for EXP (green/yellow) + exp_label.label = "+" + str(int(amount)) + " EXP" + exp_label.color = Color(0.4, 1.0, 0.4) # Bright green + exp_label.z_index = 5 + + # Direction is straight up + exp_label.direction = Vector2(0, -1) + + # Position at the specified location + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + entities_node.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) + func _flash_damage(): # Flash red visual effect if sprite: @@ -565,7 +607,7 @@ func _apply_burn_debuff(): if burn_debuff_timer > 0.0: # Already burning - refresh duration burn_debuff_timer = burn_debuff_duration - burn_damage_timer = 0.0 # Reset damage timer + burn_damage_timer = 0.0 # Reset damage timer LogManager.log(str(name) + " burn debuff refreshed", LogManager.CATEGORY_ENEMY) return @@ -597,7 +639,7 @@ func _create_burn_debuff_visual(): add_child(burn_debuff_visual) # Position on enemy (centered) burn_debuff_visual.position = Vector2(0, 0) - burn_debuff_visual.z_index = 5 # Above enemy sprite + burn_debuff_visual.z_index = 5 # Above enemy sprite LogManager.log(str(name) + " created burn debuff visual", LogManager.CATEGORY_ENEMY) else: # Fallback: create simple sprite if scene doesn't exist @@ -610,7 +652,7 @@ func _create_burn_debuff_visual(): burn_sprite.vframes = 4 burn_sprite.frame = 0 burn_sprite.position = Vector2(0, 0) - burn_sprite.z_index = 5 # Above enemy sprite + burn_sprite.z_index = 5 # Above enemy sprite burn_sprite.set_meta("burn_animation_frame", 0) burn_sprite.set_meta("burn_animation_timer", 0.0) add_child(burn_sprite) @@ -661,16 +703,11 @@ func _die(): game_world.defeated_enemies[enemy_index] = true LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY) - # Credit kill and grant EXP to the player who dealt the fatal damage + # Credit kill to the player who dealt the fatal damage if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: killer_player.character_stats.kills += 1 LogManager.log(str(name) + " kill credited to " + str(killer_player.name) + " (total kills: " + str(killer_player.character_stats.kills) + ")", LogManager.CATEGORY_ENEMY) - # Grant EXP to the killer - if exp_reward > 0: - killer_player.character_stats.add_xp(exp_reward) - LogManager.log(str(name) + " granted " + str(exp_reward) + " EXP to " + str(killer_player.name), LogManager.CATEGORY_ENEMY) - # Sync kill update to client if this player belongs to a client # Only sync if we're on the server and the killer is a client's player if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): @@ -679,8 +716,41 @@ func _die(): if killer_peer_id != 0 and killer_peer_id != multiplayer.get_unique_id() and killer_player.has_method("_sync_stats_update"): # Server is updating a client's player stats - sync to the client var coins = killer_player.character_stats.coin if "coin" in killer_player.character_stats else 0 - LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins), LogManager.CATEGORY_ENEMY) - killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins) + var xp = killer_player.character_stats.xp if "xp" in killer_player.character_stats else 0.0 + LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins) + " xp=" + str(xp), LogManager.CATEGORY_ENEMY) + killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins, xp) + + # Grant EXP to all players (split evenly among all players) + if exp_reward > 0: + var all_players = get_tree().get_nodes_in_group("player") + var valid_players = [] + for player in all_players: + if is_instance_valid(player) and player.character_stats: + valid_players.append(player) + + if valid_players.size() > 0: + # Split EXP evenly among all players + var exp_per_player = exp_reward / valid_players.size() + for player in valid_players: + player.character_stats.add_xp(exp_per_player) + LogManager.log(str(name) + " granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(exp_reward) + " total)", LogManager.CATEGORY_ENEMY) + + # Sync EXP to client if this player belongs to a client + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): + var player_peer_id = player.get_multiplayer_authority() + if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"): + var coins = player.character_stats.coin if "coin" in player.character_stats else 0 + var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0 + player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp) + + # Show floating EXP text at enemy position and sync to all clients + if is_multiplayer_authority(): + # Show locally first + _show_exp_number(exp_per_player, global_position) + # Sync to all clients via game_world + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): + game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position) # Spawn loot immediately (before death animation) _spawn_loot() @@ -720,7 +790,7 @@ func _spawn_loot(): return # Get killer's LCK stat to influence loot drops - var killer_lck = 10.0 # Default LCK if no killer + var killer_lck = 10.0 # Default LCK if no killer if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: killer_lck = killer_player.character_stats.baseStats.lck + killer_player.character_stats.get_pass("lck") LogManager.log(str(name) + " killed by " + str(killer_player.name) + " with LCK: " + str(killer_lck), LogManager.CATEGORY_ENEMY) @@ -728,7 +798,7 @@ func _spawn_loot(): # Random chance to drop loot (85% chance - increased from 70%) # LCK can increase this: +0.01% per LCK point (capped at 95%) var base_loot_chance = 0.85 - var lck_bonus = min(killer_lck * 0.001, 0.1) # +0.1% per LCK, max +10% (95% cap) + var lck_bonus = min(killer_lck * 0.001, 0.1) # +0.1% per LCK, max +10% (95% cap) var loot_chance = randf() var loot_threshold = 1.0 - (base_loot_chance + lck_bonus) LogManager.log(str(name) + " loot chance roll: " + str(loot_chance) + " (need > " + str(loot_threshold) + ", base=" + str(base_loot_chance) + ", LCK bonus=" + str(lck_bonus) + ")", LogManager.CATEGORY_ENEMY) @@ -736,18 +806,18 @@ func _spawn_loot(): # Determine how many loot items to drop (1-4 items, influenced by LCK) # Base: 1-3 items, LCK can push towards 2-4 items # LCK effect: Each 5 points of LCK above 10 increases chance for extra drops - var lck_modifier = (killer_lck - 10.0) / 5.0 # +1 per 5 LCK above 10 + var lck_modifier = (killer_lck - 10.0) / 5.0 # +1 per 5 LCK above 10 var num_drops_roll = randf() - var base_num_drops_roll = num_drops_roll - (lck_modifier * 0.1) # LCK reduces roll needed (up to -0.6 at LCK 40) + var base_num_drops_roll = num_drops_roll - (lck_modifier * 0.1) # LCK reduces roll needed (up to -0.6 at LCK 40) var num_drops = 1 if base_num_drops_roll < 0.5: - num_drops = 1 # 50% base chance for 1 item (reduced from 60%) + num_drops = 1 # 50% base chance for 1 item (reduced from 60%) elif base_num_drops_roll < 0.8: - num_drops = 2 # 30% base chance for 2 items + num_drops = 2 # 30% base chance for 2 items elif base_num_drops_roll < 0.95: - num_drops = 3 # 15% base chance for 3 items + num_drops = 3 # 15% base chance for 3 items else: - num_drops = 4 # 5% base chance for 4 items (LCK makes this more likely) + num_drops = 4 # 5% base chance for 4 items (LCK makes this more likely) # Ensure at least 1 drop num_drops = max(1, num_drops) @@ -768,13 +838,14 @@ func _spawn_loot(): for i in range(num_drops): # Decide what to drop for this item, influenced by LCK # LCK makes better items more likely: reduces coin chance, increases item chance - var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+ - var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+ + var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+ + var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+ - # Base probabilities: 50% coin, 20% food, 30% item - var coin_chance = 0.5 - lck_penalty_coin - var food_chance = 0.2 - var item_chance = 0.3 + lck_bonus_item + # Base probabilities: 70% coin, 15% food, 15% item (reduced from 30%) + # Items are further split: 80% consumables, 20% equipment (making equipment very rare) + var coin_chance = 0.7 - lck_penalty_coin + var food_chance = 0.15 + var item_chance = 0.15 + lck_bonus_item # Reduced from 0.3 # Normalize probabilities var total = coin_chance + food_chance + item_chance @@ -785,7 +856,7 @@ func _spawn_loot(): var drop_roll = randf() var loot_type = 0 var drop_item = false - var item_rarity_boost = false # LCK can boost item rarity + var item_rarity_boost = false # LCK can boost item rarity if drop_roll < coin_chance: # Coin @@ -827,12 +898,12 @@ func _spawn_loot(): # Create unique seed for this loot item: dungeon_seed + loot_id # This ensures each loot item gets a unique but deterministic seed - var loot_seed = base_seed + loot_id + 10000 # Offset to avoid collisions + var loot_seed = base_seed + loot_id + 10000 # Offset to avoid collisions loot_rng.seed = loot_seed var random_angle = loot_rng.randf() * PI * 2 - var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed - var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed + var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed + var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed # Generate initial velocity (same on all clients via RPC) var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force @@ -844,42 +915,22 @@ func _spawn_loot(): safe_spawn_pos = game_world._find_nearby_safe_spawn_position(safe_spawn_pos, 32.0) if drop_item: - # Spawn Item instance as loot - LCK influences rarity + # Spawn Item instance as loot - prioritize consumables over equipment + # 80% consumables (arrows, bombs, restoration), 20% equipment + var item_type_roll = randf() var item = null - if item_rarity_boost: - # High LCK: use chest rarity weights (better loot) instead of enemy drop weights - # Roll for rarity with LCK bonus: each 5 LCK above 15 increases rare/epic chance - var rarity_roll = randf() - var lck_rarity_bonus = min((killer_lck - 15.0) * 0.02, 0.15) # Up to +15% rare/epic chance - - # Clamp values to prevent going below 0 or above 1 - var common_threshold = max(0.0, 0.3 - lck_rarity_bonus) - var uncommon_threshold = max(common_threshold, 0.65 - (lck_rarity_bonus * 0.5)) - var rare_threshold = min(1.0, 0.90 + (lck_rarity_bonus * 2.0)) - - if rarity_roll < common_threshold: - # Common (reduced by LCK) - item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.COMMON) - elif rarity_roll < uncommon_threshold: - # Uncommon (slightly reduced) - item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.UNCOMMON) - elif rarity_roll < rare_threshold: - # Rare (increased by LCK) - item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.RARE) - else: - # Epic/Consumable (greatly increased by LCK) - var epic_roll = randf() - if epic_roll < 0.5: - item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.EPIC) - else: - item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.CONSUMABLE) + + if item_type_roll < 0.8: + # Consumable drop (arrows, bombs, restoration items) + item = ItemDatabase.get_random_consumable_drop() else: - # Normal LCK: use standard enemy drop weights - item = ItemDatabase.get_random_enemy_drop() + # Equipment drop (much rarer - only 20% of item drops, which is 20% of 15% = 3% total) + # LCK boost still applies - higher LCK makes equipment drops more likely to be better quality + item = ItemDatabase.get_random_equipment_drop() if item: ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world) - LogManager.log(str(name) + " ✓ dropped item #" + str(i+1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY) + LogManager.log(str(name) + " ✓ dropped item #" + str(i + 1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY) else: # Spawn regular loot (coin or food) var loot = loot_scene.instantiate() @@ -891,7 +942,7 @@ func _spawn_loot(): loot.velocity_z = random_velocity_z loot.velocity_set_by_spawner = true loot.is_airborne = true - LogManager.log(str(name) + " ✓ dropped loot #" + str(i+1) + ": " + str(loot_type) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY) + LogManager.log(str(name) + " ✓ dropped loot #" + str(i + 1) + ": " + str(loot_type) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY) # Sync loot spawn to all clients (use safe position) if multiplayer.has_multiplayer_peer(): @@ -908,8 +959,8 @@ func _spawn_loot(): loot_rng.seed = real_loot_seed # Regenerate velocity with correct seed var real_random_angle = loot_rng.randf() * PI * 2 - var real_random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed - var real_random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed + var real_random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed + var real_random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed initial_velocity = Vector2(cos(real_random_angle), sin(real_random_angle)) * real_random_force random_velocity_z = real_random_velocity_z # Update loot with correct velocity @@ -922,7 +973,7 @@ func _spawn_loot(): loot.set_meta("loot_id", loot_id) # Sync to clients with ID game_world._rpc_to_ready_peers("_sync_loot_spawn", [safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id]) - LogManager.log(str(name) + " ✓ synced loot #" + str(i+1) + " spawn to clients", LogManager.CATEGORY_ENEMY) + LogManager.log(str(name) + " ✓ synced loot #" + str(i + 1) + " spawn to clients", LogManager.CATEGORY_ENEMY) else: LogManager.log_error(str(name) + " ERROR: game_world not found for loot sync!", LogManager.CATEGORY_ENEMY) else: @@ -1015,8 +1066,8 @@ func _sync_death(): var current_state = get("state") # SlimeState enum: IDLE=0, MOVING=1, JUMPING=2, DAMAGED=3, DYING=4 # Set state to DYING (4) if it's currently DAMAGED (3) or less - if current_state <= 3: # DAMAGED or less - set("state", 4) # Set to DYING + if current_state <= 3: # DAMAGED or less + set("state", 4) # Set to DYING LogManager.log(str(name) + " (client) set state to DYING (4) in _sync_death to override DAMAGED state", LogManager.CATEGORY_ENEMY) # For humanoid enemies, ensure death animation is set immediately and animation state is reset diff --git a/src/scripts/enemy_hand.gd b/src/scripts/enemy_hand.gd index 7ddd1c4..fd20e47 100644 --- a/src/scripts/enemy_hand.gd +++ b/src/scripts/enemy_hand.gd @@ -4,7 +4,7 @@ extends "res://scripts/enemy_base.gd" # Moves toward player if in PlayerInterestArea, else random. Collides with walls. # If player enters GrabPlayerArea and alive: grab, lock player, snatch anim, deal damage, release + knockback. -enum HandState { HIDDEN, EMERGING, IDLE, GRABBING } +enum HandState {HIDDEN, EMERGING, IDLE, GRABBING} var state: HandState = HandState.HIDDEN var players_in_interest: Array[Node] = [] @@ -15,9 +15,9 @@ var grab_cooldown_timer: float = 0.0 const RANDOM_MOVE_INTERVAL: float = 1.2 const SNATCH_DURATION: float = 0.4 const SNATCH_DAMAGE: float = 12.0 -const GRAB_COOLDOWN: float = 2.0 # Cooldown after grabbing before can grab again +const GRAB_COOLDOWN: float = 2.0 # Cooldown after grabbing before can grab again const TILE_SIZE: int = 16 -const TILE_STRIDE: int = 17 # 16 + separation 1 +const TILE_STRIDE: int = 17 # 16 + separation 1 @onready var emerge_area: Area2D = $EmergeArea @onready var grab_area: Area2D = $GrabPlayerArea @onready var interest_area: Area2D = $PlayerInterestArea @@ -31,7 +31,7 @@ func _ready() -> void: super._ready() max_health = 25.0 current_health = max_health - move_speed = 28.0 # Reduced from 55.0 - much slower + move_speed = 16.8 # 60% of 28.0 - slower chase/random movement damage = SNATCH_DAMAGE exp_reward = 8.0 collision_layer = 2 @@ -163,18 +163,18 @@ func _spawn_hand_pieces(): # 4 quadrants: top-left, top-right, bottom-left, bottom-right var regions = [ - Rect2(base_x, base_y, half_width, half_height), # Top-left - Rect2(base_x + half_width, base_y, half_width, half_height), # Top-right - Rect2(base_x, base_y + half_height, half_width, half_height), # Bottom-left - Rect2(base_x + half_width, base_y + half_height, half_width, half_height) # Bottom-right + Rect2(base_x, base_y, half_width, half_height), # Top-left + Rect2(base_x + half_width, base_y, half_width, half_height), # Top-right + Rect2(base_x, base_y + half_height, half_width, half_height), # Bottom-left + Rect2(base_x + half_width, base_y + half_height, half_width, half_height) # Bottom-right ] # 4 directions: up-left, up-right, down-left, down-right var directions = [ - Vector2(-1, -1).normalized(), # Up-left - Vector2(1, -1).normalized(), # Up-right - Vector2(-1, 1).normalized(), # Down-left - Vector2(1, 1).normalized() # Down-right + Vector2(-1, -1).normalized(), # Up-left + Vector2(1, -1).normalized(), # Up-right + Vector2(-1, 1).normalized(), # Down-left + Vector2(1, 1).normalized() # Down-right ] # Spawn 4 pieces @@ -195,11 +195,11 @@ func _spawn_hand_pieces(): # Fly in the direction for this piece var direction = directions[i] - var speed = randf_range(200.0, 300.0) # Fast enough to see them fly + var speed = randf_range(200.0, 300.0) # Fast enough to see them fly p.velocity = direction * speed p.angular_velocity = randf_range(-10.0, 10.0) p.position_z = 0.0 - p.velocity_z = randf_range(80.0, 140.0) # Upward burst, then gravity + p.velocity_z = randf_range(80.0, 140.0) # Upward burst, then gravity # Use call_deferred to avoid physics query flush errors parent.call_deferred("add_child", p) @@ -217,7 +217,7 @@ func _ai_behavior(delta: float) -> void: velocity = Vector2.ZERO # Update grabbed player position to follow hand (slightly above) if grabbed_player and is_instance_valid(grabbed_player): - var target_pos = global_position + Vector2(0, -12) # Slightly above the hand + var target_pos = global_position + Vector2(0, -12) # Slightly above the hand # Smoothly move player to hand position (only on authority) if is_multiplayer_authority(): grabbed_player.global_position = grabbed_player.global_position.lerp(target_pos, delta * 8.0) @@ -300,7 +300,7 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void: if grabbed_player != null: return if grab_cooldown_timer > 0.0: - return # Still on cooldown from previous grab + return # Still on cooldown from previous grab if not is_multiplayer_authority(): return @@ -327,7 +327,7 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void: func _sync_hand_emerged(): # Sync hand emergence visibility to clients if is_multiplayer_authority(): - return # Authority already handled it locally + return # Authority already handled it locally if state == HandState.HIDDEN: state = HandState.EMERGING @@ -341,7 +341,7 @@ func _finish_snatch() -> void: var victim = grabbed_player grabbed_player = null state = HandState.IDLE - grab_cooldown_timer = GRAB_COOLDOWN # Start cooldown after grab + grab_cooldown_timer = GRAB_COOLDOWN # Start cooldown after grab if anim_player and anim_player.has_animation("idle"): anim_player.play("idle") if not is_instance_valid(victim): @@ -402,14 +402,14 @@ func _spawn_emerge_tile_particles(): # Get the tile at the hand's position var cell = center_cell if layer.get_cell_source_id(cell) < 0: - return # No tile at this position + return # No tile at this position var atlas = layer.get_cell_atlas_coords(cell) var world = layer.map_to_local(cell) + layer.global_position var bx = atlas.x * TILE_STRIDE var by = atlas.y * TILE_STRIDE - var h = 8.0 # TILE_SIZE / 2 + var h = 8.0 # TILE_SIZE / 2 var regions = [ Rect2(bx, by, h, h), Rect2(bx + h, by, h, h), @@ -434,11 +434,11 @@ func _spawn_emerge_tile_particles(): # Particles fly outward in random directions (less intense than bomb) var angle = randf() * TAU var d = Vector2(cos(angle), sin(angle)) - var speed = randf_range(150.0, 250.0) # Slower than bomb explosion + var speed = randf_range(150.0, 250.0) # Slower than bomb explosion p.velocity = d * speed p.angular_velocity = randf_range(-8.0, 8.0) p.position_z = 0.0 - p.velocity_z = randf_range(60.0, 120.0) # Upward burst, then gravity + p.velocity_z = randf_range(60.0, 120.0) # Upward burst, then gravity # Use call_deferred to avoid physics query flush errors parent.call_deferred("add_child", p) diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index 2703b69..5c100b4 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -39,39 +39,39 @@ var can_attack: bool = true var is_attacking: bool = false var is_charging_attack: bool = false var attack_charge_time: float = 0.0 -var base_attack_charge_time: float = 0.4 # Base charge time before attack -var dex: int = 10 # Dexterity stat (affects attack speed) +var base_attack_charge_time: float = 0.4 # Base charge time before attack +var dex: int = 10 # Dexterity stat (affects attack speed) var blood_scene = preload("res://scenes/blood_clot.tscn") # Bow charge visual effect (pulsing) -var bow_charge_tint: Color = Color(1.2, 1.2, 1.2, 1.0) # White tint when fully charged -var bow_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation -var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged -var original_sprite_tints: Dictionary = {} # Store original tint values for restoration +var bow_charge_tint: Color = Color(1.2, 1.2, 1.2, 1.0) # White tint when fully charged +var bow_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation +var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged +var original_sprite_tints: Dictionary = {} # Store original tint values for restoration # Loadout (player-like abilities) — some humanoids have bow, bomb, spell, shield, lift/throw var has_bow: bool = false var arrows_left: int = 0 var has_bomb: bool = false var bombs_left: int = 0 -var spell_type: String = "" # "flames" | "frost" | "healing" | "" +var spell_type: String = "" # "flames" | "frost" | "healing" | "" var has_shield: bool = false var shield_block_chance: float = 0.0 -var is_blocking: bool = false # Whether enemy is actively blocking with shield -var shield_block_speed_multiplier: float = 0.5 # Movement speed multiplier when blocking -var shield_block_timer: float = 0.0 # Timer for how long to keep blocking -var shield_block_duration: float = 1.5 # How long to block after raising shield +var is_blocking: bool = false # Whether enemy is actively blocking with shield +var shield_block_speed_multiplier: float = 0.5 # Movement speed multiplier when blocking +var shield_block_timer: float = 0.0 # Timer for how long to keep blocking +var shield_block_duration: float = 1.5 # How long to block after raising shield var can_lift_throw: bool = false var spell_cooldown_timer: float = 0.0 var bomb_cooldown_timer: float = 0.0 var lift_throw_cooldown_timer: float = 0.0 -var ranged_decision_timer: float = 0.0 # Roll for bow/bomb/spell every 0.4s +var ranged_decision_timer: float = 0.0 # Roll for bow/bomb/spell every 0.4s var attack_arrow_scene: PackedScene = preload("res://scenes/attack_arrow.tscn") var attack_bomb_scene: PackedScene = preload("res://scenes/attack_bomb.tscn") var flame_spell_scene: PackedScene = preload("res://scenes/attack_spell_flame.tscn") var frostspike_spell_scene: PackedScene = preload("res://scenes/attack_spell_frostspike.tscn") var interactable_object_scene: PackedScene = preload("res://scenes/interactable_object.tscn") -var held_bomb_object: Node = null # Bomb object held above enemy's head before throwing +var held_bomb_object: Node = null # Bomb object held above enemy's head before throwing # AI state enum AIState {IDLE, WANDERING, NOTICED, CHASING, ATTACKING, GROUPING, BOW_CHARGING, THROWING_BOMB, CASTING_SPELL, LIFTING} @@ -225,7 +225,7 @@ func _ready(): super._ready() # CRITICAL: Ensure collision mask is set correctly (walls are on layer 7 = bit 6 = 64) - collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64) + collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64) # Override sprite reference (we use layered sprites, not single sprite) sprite = null # Don't use base class sprite @@ -361,7 +361,7 @@ func _randomize_appearance(): Color(0.2, 0.2, 0.9), # Bright blue Color(0.9, 0.4, 0.6), # Hot pink Color(0.5, 0.2, 0.8), # Deep purple - Color(0.9, 0.6, 0.1) # Amber + Color(0.9, 0.6, 0.1) # Amber ] var hair_color = hair_colors[appearance_rng.randi() % hair_colors.size()] _set_hair_color(hair_color) @@ -601,7 +601,6 @@ func _get_body_texture_for_type(type: HumanoidType) -> String: func _load_random_equipment(): # Load random equipment (shoes, clothes, gloves, headgear) # Equipment is optional - chance to have each piece - # Random shoes (Layer 1 - Shoes) if appearance_rng.randf() < 0.8: # 80% chance to have shoes _load_random_shoes() @@ -701,7 +700,7 @@ func _load_random_gloves(): # Only load gloves if we don't already have clothes # This prevents gloves from overriding clothes if sprite_armour.texture: - return # Already have clothes, skip gloves + return # Already have clothes, skip gloves # Available gloves var gloves = [ @@ -872,60 +871,60 @@ func _setup_stats(): # Set stats based on type match humanoid_type: HumanoidType.CYCLOPS: - max_health = 55.0 # Reduced from 100.0 for better balance + max_health = 55.0 # Reduced from 100.0 for better balance move_speed = 40.0 damage = 15.0 - dex = 8 # Slow, strong - exp_reward = 25.0 # Strong enemies give more EXP + dex = 8 # Slow, strong + exp_reward = 25.0 # Strong enemies give more EXP HumanoidType.DEMON: - max_health = 45.0 # Reduced from 80.0 for better balance + max_health = 45.0 # Reduced from 80.0 for better balance move_speed = 45.0 damage = 12.0 - dex = 12 # Medium speed + dex = 12 # Medium speed exp_reward = 20.0 HumanoidType.HUMANOID: - max_health = 35.0 # Reduced from 60.0 for better balance + max_health = 35.0 # Reduced from 60.0 for better balance move_speed = 50.0 damage = 10.0 - dex = 15 # Fast, agile + dex = 15 # Fast, agile exp_reward = 15.0 HumanoidType.NIGHTELF: - max_health = 40.0 # Reduced from 70.0 for better balance + max_health = 40.0 # Reduced from 70.0 for better balance move_speed = 55.0 damage = 11.0 - dex = 18 # Very fast + dex = 18 # Very fast exp_reward = 18.0 HumanoidType.GOBLIN: - max_health = 25.0 # Reduced from 40.0 for better balance + max_health = 25.0 # Reduced from 40.0 for better balance move_speed = 60.0 damage = 8.0 - dex = 20 # Very fast, weak - exp_reward = 10.0 # Weak enemies give less EXP + dex = 20 # Very fast, weak + exp_reward = 10.0 # Weak enemies give less EXP HumanoidType.ORC: - max_health = 50.0 # Reduced from 90.0 for better balance + max_health = 50.0 # Reduced from 90.0 for better balance move_speed = 42.0 damage = 14.0 - dex = 7 # Slow, very strong + dex = 7 # Slow, very strong exp_reward = 22.0 HumanoidType.SKELETON: - max_health = 30.0 # Reduced from 50.0 for better balance + max_health = 30.0 # Reduced from 50.0 for better balance move_speed = 48.0 damage = 9.0 - dex = 14 # Medium-fast + dex = 14 # Medium-fast exp_reward = 12.0 current_health = max_health # Calculate attack cooldown based on DEX (higher DEX = faster attacks) # Base cooldown of 1.5s, reduced by DEX (max reduction to 0.5s at DEX 20) - var dex_multiplier = 1.0 - (dex - 5) * 0.05 # Each point of DEX above 5 reduces cooldown by 5% - dex_multiplier = clamp(dex_multiplier, 0.33, 1.0) # Clamp between 0.33x (3x faster) and 1.0x + var dex_multiplier = 1.0 - (dex - 5) * 0.05 # Each point of DEX above 5 reduces cooldown by 5% + dex_multiplier = clamp(dex_multiplier, 0.33, 1.0) # Clamp between 0.33x (3x faster) and 1.0x attack_cooldown = 1.5 * dex_multiplier # Calculate attack charge time based on DEX (higher DEX = shorter charge) # Base charge of 0.4s, reduced by DEX (min charge of 0.15s at DEX 20) - var charge_multiplier = 1.0 - (dex - 5) * 0.02 # Each point of DEX above 5 reduces charge by 2% - charge_multiplier = clamp(charge_multiplier, 0.375, 1.0) # Clamp between 0.375x (2.67x faster) and 1.0x + var charge_multiplier = 1.0 - (dex - 5) * 0.02 # Each point of DEX above 5 reduces charge by 2% + charge_multiplier = clamp(charge_multiplier, 0.375, 1.0) # Clamp between 0.375x (2.67x faster) and 1.0x base_attack_charge_time = 0.4 * charge_multiplier LogManager.log(str(name) + " stats: DEX=" + str(dex) + " attack_cooldown=" + str(attack_cooldown) + " charge_time=" + str(base_attack_charge_time), LogManager.CATEGORY_ENEMY) @@ -1235,7 +1234,7 @@ func _chasing_behavior(delta_arg): if has_shield and shield_block_chance > 0: # Check if player is attacking (recently attacked or in melee range) var player_is_attacking = false - if dist < 60.0: # Close enough that player might attack + if dist < 60.0: # Close enough that player might attack # Check if player is facing us and might be attacking if "is_attacking" in target_player and target_player.is_attacking: player_is_attacking = true @@ -1281,12 +1280,12 @@ func _chasing_behavior(delta_arg): attack_charge_time = base_attack_charge_time * 1.2 velocity = Vector2.ZERO current_direction = _get_direction_from_vector(to_player) - bow_charge_tint_pulse_time = 0.0 # Reset pulse timer + bow_charge_tint_pulse_time = 0.0 # Reset pulse timer return elif has_bomb and bombs_left > 0 and bomb_cooldown_timer <= 0 and dist >= 48 and dist <= 130: if randf() < 0.12: ai_state = AIState.THROWING_BOMB - state_timer = 2.0 # Increased timer: 1.0s to hold bomb visible, 1.0s for throw animation + state_timer = 2.0 # Increased timer: 1.0s to hold bomb visible, 1.0s for throw animation velocity = Vector2.ZERO current_direction = _get_direction_from_vector(to_player) # Create bomb object above enemy's head @@ -1323,7 +1322,7 @@ func _chasing_behavior(delta_arg): if dist > desired_distance: # Still too far - chase player - velocity = to_player * move_speed * 0.8 * speed_mult # Apply blocking speed reduction + velocity = to_player * move_speed * 0.8 * speed_mult # Apply blocking speed reduction else: # Close enough to attack - but only stop if we can attack soon # If attack is on cooldown, keep following at reduced speed to maintain distance @@ -1339,7 +1338,7 @@ func _chasing_behavior(delta_arg): # Move slightly away if too close, or maintain distance if dist < desired_distance * 0.8: # Too close - back away slightly - velocity = -to_player * move_speed * 0.3 * speed_mult + velocity = - to_player * move_speed * 0.3 * speed_mult else: # Good distance - just face player (or move slowly if blocking) if is_blocking: @@ -1400,7 +1399,7 @@ func _attacking_behavior(delta): # Set idle animation during charge if current_animation != "IDLE" and current_animation != "SWORD" and current_animation != "DAMAGE": _set_animation("IDLE") - return # Don't return to chasing yet + return # Don't return to chasing yet # Return to chasing after attack completes # Check if attack animation is done (not in SWORD animation anymore) and cooldown is over @@ -1490,7 +1489,7 @@ func _perform_bow_attack(): # Fire multiple arrows in a volley (2-3 arrows) var num_arrows = randi_range(2, 3) - num_arrows = min(num_arrows, arrows_left) # Don't fire more than available + num_arrows = min(num_arrows, arrows_left) # Don't fire more than available var base_dir = _get_attack_direction_vector() var charge_pct = 0.65 + randf() * 0.25 @@ -1499,7 +1498,7 @@ func _perform_bow_attack(): for i in range(num_arrows): var dir = base_dir # Add spread to arrows (cone pattern) - var spread_angle = deg_to_rad(randf_range(-12.0, 12.0)) # ±12 degrees spread + var spread_angle = deg_to_rad(randf_range(-12.0, 12.0)) # ±12 degrees spread dir = dir.rotated(spread_angle) # Add additional aim error dir = _add_aim_error(dir, randf_range(16.0, 28.0)) @@ -1535,12 +1534,12 @@ func _throwing_bomb_behavior(delta): # Update held bomb position above enemy's head if held_bomb_object and is_instance_valid(held_bomb_object): # Position bomb above enemy's head (offset upward) - var head_offset = Vector2(0, -20) # Above the head + var head_offset = Vector2(0, -20) # Above the head held_bomb_object.global_position = global_position + head_offset # Make sure bomb is visible and on top if held_bomb_object.has_node("Sprite2D"): held_bomb_object.get_node("Sprite2D").visible = true - held_bomb_object.z_index = 10 # Above enemy sprites + held_bomb_object.z_index = 10 # Above enemy sprites # Face the player while holding bomb current_direction = _get_direction_from_vector((target_player.global_position - global_position).normalized()) @@ -1589,8 +1588,8 @@ func _create_held_bomb_object(): var bomb_obj = interactable_object_scene.instantiate() bomb_obj.name = "EnemyHeldBomb_" + str(Time.get_ticks_msec()) - bomb_obj.global_position = global_position + Vector2(0, -20) # Above head - bomb_obj.z_index = 10 # Above enemy sprites + bomb_obj.global_position = global_position + Vector2(0, -20) # Above head + bomb_obj.z_index = 10 # Above enemy sprites # Set multiplayer authority if multiplayer.has_multiplayer_peer(): @@ -1605,7 +1604,7 @@ func _create_held_bomb_object(): bomb_obj.set_collision_layer_value(2, false) bomb_obj.set_collision_mask_value(1, false) bomb_obj.set_collision_mask_value(2, false) - bomb_obj.set_collision_mask_value(7, true) # Keep wall collision + bomb_obj.set_collision_mask_value(7, true) # Keep wall collision # Make sure sprite is visible if bomb_obj.has_node("Sprite2D"): @@ -1630,8 +1629,14 @@ func _throw_held_bomb(): var par = get_parent() if par: par.add_child(bomb) - bomb.global_position = global_position + Vector2(0, -20) # From above head + bomb.global_position = global_position + Vector2(0, -20) # From above head bomb.setup(bomb.global_position, self, fallback_throw_force, true) + # Sync fallback bomb to joiners + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers") and multiplayer.has_multiplayer_peer(): + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 + var bomb_name = "EnemyBomb_" + name + "_" + str(Time.get_ticks_msec()) + game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, name, enemy_index, bomb.global_position, fallback_throw_force]) return if bombs_left <= 0: @@ -1757,7 +1762,7 @@ func _perform_attack(): can_attack = false is_attacking = true - is_charging_attack = false # Reset charging flag + is_charging_attack = false # Reset charging flag # CRITICAL: Set attack timer for cooldown (this will reset can_attack when it expires) attack_timer = attack_cooldown @@ -1766,7 +1771,7 @@ func _perform_attack(): _set_animation("SWORD") # Set state timer to allow attack animation to complete before returning to chasing - state_timer = attack_cooldown + 0.3 # Give extra time for attack animation + state_timer = attack_cooldown + 0.3 # Give extra time for attack animation # Calculate attack direction var attack_direction = Vector2.ZERO @@ -1883,7 +1888,7 @@ func _update_animation(delta): elif "IDLE" in ANIMATIONS: current_animation = "IDLE" else: - return # Can't update animation without valid animation + return # Can't update animation without valid animation time_since_last_frame += delta if time_since_last_frame >= ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0: @@ -2036,7 +2041,7 @@ func _apply_bow_charge_tint(): ] # Calculate pulse value (0.0 to 1.0) using sine wave - var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 for sprite_data in sprite_layers: var sprite_layer = sprite_data.sprite @@ -2071,7 +2076,7 @@ func _apply_bow_charge_tint(): ) # Interpolate between original and charged tint based on pulse - var current_tint = original_tint.lerp(full_charged_tint, pulse_value * 0.5) # 50% pulse intensity + var current_tint = original_tint.lerp(full_charged_tint, pulse_value * 0.5) # 50% pulse intensity shader_material.set_shader_parameter("tint", Vector4(current_tint.r, current_tint.g, current_tint.b, current_tint.a)) func _clear_bow_charge_tint(): @@ -2173,7 +2178,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals # Attack not blocked, but raise shield anyway if we have one (defensive reaction) if not is_blocking: is_blocking = true - shield_block_timer = shield_block_duration * 0.8 # Shorter duration for reactive blocking + shield_block_timer = shield_block_duration * 0.8 # Shorter duration for reactive blocking _update_shield_visibility() if sfx_activate_shield: sfx_activate_shield.play() diff --git a/src/scripts/game_state.gd b/src/scripts/game_state.gd new file mode 100644 index 0000000..1f40da1 --- /dev/null +++ b/src/scripts/game_state.gd @@ -0,0 +1,9 @@ +extends Node +# Persists selected race across scene change (main menu -> select class -> game world). +# Player reads this in _setup_player_appearance() and randomizes character for that race. +# Set skip_race_select = true (e.g. via --race=elf) to go straight to game without race select UI. +# race_chosen_before_connect: joiner picked race on main menu before Join; skip race select after connect. + +var selected_race: String = "Dwarf" +var skip_race_select: bool = false +var race_chosen_before_connect: bool = false diff --git a/src/scripts/game_state.gd.uid b/src/scripts/game_state.gd.uid new file mode 100644 index 0000000..037b8c1 --- /dev/null +++ b/src/scripts/game_state.gd.uid @@ -0,0 +1 @@ +uid://6dgu8mbartys diff --git a/src/scripts/game_ui.gd b/src/scripts/game_ui.gd index 58865e7..8e368af 100644 --- a/src/scripts/game_ui.gd +++ b/src/scripts/game_ui.gd @@ -5,6 +5,7 @@ extends CanvasLayer @onready var main_menu = $Control/MainMenu @onready var host_button = $Control/MainMenu/VBoxContainer/HostButton @onready var join_button = $Control/MainMenu/VBoxContainer/JoinButton +@onready var select_race_button = $Control/MainMenu/VBoxContainer/SelectRaceButton @onready var network_mode_option = $Control/MainMenu/VBoxContainer/NetworkModeContainer/NetworkModeOption @onready var network_mode_container = $Control/MainMenu/VBoxContainer/NetworkModeContainer @onready var local_players_spinbox = $Control/MainMenu/VBoxContainer/LocalPlayersContainer/SpinBox @@ -16,17 +17,22 @@ extends CanvasLayer @onready var network_manager = $"/root/NetworkManager" +var select_class_scene: PackedScene = preload("res://scenes/select_class.tscn") +var select_class_instance: Node = null # Race select overlay (before dungeon) +var _race_select_standalone: bool = false # True when joiner picks race before Join (don't start game on confirm) +var pending_auto_join_after_race_select: bool = false # --join --webrtc with no --race=: show race select first, then auto-join + var connection_error_label: Label = null -var connection_error_shown: bool = false # Prevent spamming error messages +var connection_error_shown: bool = false # Prevent spamming error messages var is_joining_attempt: bool = false var last_join_address: String = "" -var room_fetch_timer: Timer = null # Timer for retrying room fetches -var is_auto_joining: bool = false # Track if we're in auto-join mode -var is_hosting: bool = false # Track if we're hosting (don't fetch rooms when hosting) -var room_list_container: VBoxContainer = null # Container for displaying available rooms -var refresh_button: Button = null # Refresh button for manually reloading rooms -var refresh_cooldown_timer: Timer = null # Timer for refresh button cooldown -var active_room_join_button: Button = null # Join button we're currently using (reset on fail) +var room_fetch_timer: Timer = null # Timer for retrying room fetches +var is_auto_joining: bool = false # Track if we're in auto-join mode +var is_hosting: bool = false # Track if we're hosting (don't fetch rooms when hosting) +var room_list_container: VBoxContainer = null # Container for displaying available rooms +var refresh_button: Button = null # Refresh button for manually reloading rooms +var refresh_cooldown_timer: Timer = null # Timer for refresh button cooldown +var active_room_join_button: Button = null # Join button we're currently using (reset on fail) func _ready(): # Wait for nodes to be ready @@ -49,6 +55,8 @@ func _ready(): # Connect buttons host_button.pressed.connect(_on_host_pressed) join_button.pressed.connect(_on_join_pressed) + if select_race_button: + select_race_button.pressed.connect(_on_select_race_pressed) # Setup network mode dropdown if network_mode_option: @@ -87,7 +95,7 @@ func _ready(): # If WebRTC is selected at startup (not auto-joining and not hosting), fetch rooms if not is_auto_joining and not is_hosting: var current_mode = network_manager.network_mode - if current_mode == 1: # WebRTC + if current_mode == 1: # WebRTC _start_room_fetch() func _check_command_line_args(): @@ -99,7 +107,7 @@ func _check_command_line_args(): var should_join = false var should_debug = false var force_webrtc = false - var join_address = "" # Empty by default - will fetch rooms if WebRTC/WebSocket + var join_address = "" # Empty by default - will fetch rooms if WebRTC/WebSocket var local_count = 1 for arg in args: @@ -119,17 +127,35 @@ func _check_command_line_args(): join_address = arg.split("=")[1] elif arg.begins_with("--players="): local_count = int(arg.split("=")[1]) + elif arg.begins_with("--race="): + var race_arg = arg.split("=")[1].strip_edges().to_lower() + var gs = get_node_or_null("/root/GameState") + if gs: + if race_arg == "dwarf": + gs.selected_race = "Dwarf" + gs.skip_race_select = true + LogManager.log("GameUI: Race set from argument: Dwarf (skip race select)", LogManager.CATEGORY_UI) + elif race_arg == "elf": + gs.selected_race = "Elf" + gs.skip_race_select = true + LogManager.log("GameUI: Race set from argument: Elf (skip race select)", LogManager.CATEGORY_UI) + elif race_arg == "human": + gs.selected_race = "Human" + gs.skip_race_select = true + LogManager.log("GameUI: Race set from argument: Human (skip race select)", LogManager.CATEGORY_UI) + else: + LogManager.log("GameUI: Ignoring invalid --race=" + race_arg + " (use dwarf, elf, or human)", LogManager.CATEGORY_UI) LogManager.log("GameUI: Parsed flags - should_host: " + str(should_host) + ", should_join: " + str(should_join) + ", force_webrtc: " + str(force_webrtc) + ", should_debug: " + str(should_debug), LogManager.CATEGORY_UI) # Force WebRTC mode if --webrtc flag is present if force_webrtc: - network_manager.set_network_mode(1) # WebRTC + network_manager.set_network_mode(1) # WebRTC if network_mode_option: if OS.get_name() == "Web": - network_mode_option.selected = 0 # WebRTC is first option on web + network_mode_option.selected = 0 # WebRTC is first option on web else: - network_mode_option.selected = 1 # WebRTC is second option on native + network_mode_option.selected = 1 # WebRTC is second option on native _on_network_mode_changed(network_mode_option.selected) LogManager.log("GameUI: WebRTC mode forced via --webrtc flag", LogManager.CATEGORY_UI) @@ -142,33 +168,39 @@ func _check_command_line_args(): # Auto-start based on arguments if should_host: - is_hosting = true # Set flag so we don't fetch rooms + is_hosting = true # Set flag so we don't fetch rooms LogManager.log("Auto-hosting due to --host argument", LogManager.CATEGORY_UI) network_manager.set_local_player_count(local_count) if network_manager.host_game(): - _start_game() + call_deferred("_show_race_select") elif should_join: # Check network mode after it's been set var current_mode = network_manager.network_mode if join_address.is_empty() and (current_mode == 1 or current_mode == 2): - # No address provided, and using WebRTC or WebSocket - fetch and auto-join first available room - LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ")...", LogManager.CATEGORY_UI) - network_manager.set_local_player_count(local_count) - is_auto_joining = true - is_joining_attempt = true # Mark as joining attempt so connection failure handler works - # Create timer for retrying room fetches - room_fetch_timer = Timer.new() - room_fetch_timer.name = "RoomFetchTimer" - room_fetch_timer.wait_time = 2.0 # Retry every 2 seconds - room_fetch_timer.timeout.connect(_retry_room_fetch) - room_fetch_timer.autostart = false - add_child(room_fetch_timer) - # Connect to rooms_fetched signal (not one-shot, so we can keep retrying) - if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): - network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) - # Show room fetch status UI and start fetching - _show_room_fetch_status() - _start_room_fetch() + # WebRTC/WebSocket with no address: auto-join first room. If no --race=, show race select first. + var gs = get_node_or_null("/root/GameState") + if gs and gs.skip_race_select: + # --race= was passed: skip race select and auto-join immediately + LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ", race: " + gs.selected_race + ")...", LogManager.CATEGORY_UI) + network_manager.set_local_player_count(local_count) + is_auto_joining = true + is_joining_attempt = true + room_fetch_timer = Timer.new() + room_fetch_timer.name = "RoomFetchTimer" + room_fetch_timer.wait_time = 2.0 + room_fetch_timer.timeout.connect(_retry_room_fetch) + room_fetch_timer.autostart = false + add_child(room_fetch_timer) + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) + _show_room_fetch_status() + _start_room_fetch() + else: + # No --race=: show race select first; after they confirm we start auto-join + LogManager.log("Auto-join with WebRTC/WebSocket: choose race first (no --race= passed)", LogManager.CATEGORY_UI) + pending_auto_join_after_race_select = true + _race_select_standalone = true + call_deferred("_show_race_select_ui") elif not join_address.is_empty(): LogManager.log("Auto-joining to " + join_address + " due to --join argument", LogManager.CATEGORY_UI) address_input.text = join_address @@ -191,7 +223,7 @@ func _on_rooms_fetched_display(rooms: Array): # Only handle if not in auto-join mode (auto-join has its own handler) if is_auto_joining: LogManager.log("GameUI: Ignoring rooms_fetched_display - still in auto-join mode", LogManager.CATEGORY_UI) - return # Let auto-join handler take care of it + return # Let auto-join handler take care of it LogManager.log("GameUI: Received rooms for display: " + str(rooms.size()) + " rooms", LogManager.CATEGORY_UI) @@ -224,7 +256,7 @@ func _on_rooms_fetched_display(rooms: Array): func _on_rooms_fetched_auto_join(rooms: Array): """Auto-join the first available room when --join --webrtc is used without address""" if not is_auto_joining: - return # Not in auto-join mode, ignore + return # Not in auto-join mode, ignore # Hide loading indicator - request completed _hide_loading_indicator() @@ -291,6 +323,26 @@ func _on_rooms_fetched_auto_join(rooms: Array): _show_loading_indicator() _start_room_fetch() +func _start_pending_auto_join(): + """Start auto-join flow after user chose race (--join --webrtc with no --race=).""" + if main_menu: + main_menu.visible = true # Room fetch status lives inside MainMenu + var local_count = int(local_players_spinbox.value) if local_players_spinbox else 1 + network_manager.set_local_player_count(local_count) + is_auto_joining = true + is_joining_attempt = true + if not room_fetch_timer: + room_fetch_timer = Timer.new() + room_fetch_timer.name = "RoomFetchTimer" + room_fetch_timer.wait_time = 2.0 + room_fetch_timer.timeout.connect(_retry_room_fetch) + room_fetch_timer.autostart = false + add_child(room_fetch_timer) + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) + _show_room_fetch_status() + _start_room_fetch() + func _retry_room_fetch(): """Retry fetching available rooms""" if not is_auto_joining: @@ -304,7 +356,7 @@ func _retry_room_fetch(): func _start_room_fetch(): """Start fetching rooms and show loading indicator""" # Only fetch if WebRTC mode and not hosting - if network_manager.network_mode != 1: # Not WebRTC + if network_manager.network_mode != 1: # Not WebRTC return if is_hosting or network_manager.is_hosting: @@ -357,7 +409,7 @@ func _hide_room_fetch_status(): func _create_refresh_button(): """Create a refresh button for manually reloading the room list""" if refresh_button: - return # Already exists + return # Already exists if not room_fetch_status_container: return @@ -448,7 +500,7 @@ func _update_last_fetch_time(): func _create_room_list_container(): """Create the container for displaying available rooms""" if room_list_container: - return # Already exists + return # Already exists if not room_fetch_status_container: return @@ -456,7 +508,7 @@ func _create_room_list_container(): # Create a ScrollContainer for the room list var scroll_container = ScrollContainer.new() scroll_container.name = "RoomListScrollContainer" - scroll_container.custom_minimum_size = Vector2(0, 150) # Set a reasonable height + scroll_container.custom_minimum_size = Vector2(0, 150) # Set a reasonable height scroll_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Create VBoxContainer inside scroll container @@ -579,7 +631,7 @@ func _on_network_mode_changed(index: int): var actual_mode: int if OS.get_name() == "Web": # Web builds: 0 = WebRTC, 1 = WebSocket - actual_mode = index + 1 # Map 0->1 (WebRTC), 1->2 (WebSocket) + actual_mode = index + 1 # Map 0->1 (WebRTC), 1->2 (WebSocket) else: # Native builds: 0 = ENet, 1 = WebRTC, 2 = WebSocket actual_mode = index @@ -589,18 +641,18 @@ func _on_network_mode_changed(index: int): # Update address input placeholder based on mode if address_input: match actual_mode: - 0: # ENet + 0: # ENet address_input.placeholder_text = "Server IP or domain" - 1: # WebRTC + 1: # WebRTC address_input.placeholder_text = "Enter Room Code (e.g., ABC123)" - 2: # WebSocket + 2: # WebSocket address_input.placeholder_text = "Enter Room Code (e.g., ABC123)" var mode_names = ["ENet", "WebRTC", "WebSocket"] LogManager.log("GameUI: Network mode changed to: " + mode_names[actual_mode], LogManager.CATEGORY_UI) # Handle room fetching based on mode - if actual_mode == 1: # WebRTC mode + if actual_mode == 1: # WebRTC mode # Only fetch if not auto-joining and not hosting if not is_auto_joining and not is_hosting and not network_manager.is_hosting: LogManager.log("GameUI: Switched to WebRTC mode, fetching rooms", LogManager.CATEGORY_UI) @@ -613,26 +665,26 @@ func _on_network_mode_changed(index: int): _start_room_fetch() else: LogManager.log("GameUI: Switched to WebRTC mode but skipping room fetch (auto_joining: " + str(is_auto_joining) + ", hosting: " + str(is_hosting) + ")", LogManager.CATEGORY_UI) - else: # Not WebRTC mode (ENet or WebSocket) + else: # Not WebRTC mode (ENet or WebSocket) # Hide room fetch status if switching away from WebRTC LogManager.log("GameUI: Switched away from WebRTC mode, hiding room fetch UI", LogManager.CATEGORY_UI) _hide_room_fetch_status() func _on_host_pressed(): - is_hosting = true # Set flag so we don't fetch rooms + is_hosting = true # Set flag so we don't fetch rooms var local_count = int(local_players_spinbox.value) network_manager.set_local_player_count(local_count) if network_manager.host_game(): var mode = network_manager.network_mode - if mode == 1 or mode == 2: # WebRTC or WebSocket + if mode == 1 or mode == 2: # WebRTC or WebSocket var room_id = network_manager.get_room_id() var mode_name = "WebRTC" if mode == 1 else "WebSocket" print("Hosting ", mode_name, " game - Room Code: ", room_id) print("Share this code with players!") else: print("Hosting ENet game with ", local_count, " local players") - _start_game() + _show_race_select() func _on_join_pressed(): # Reset error state when attempting new connection @@ -642,10 +694,10 @@ func _on_join_pressed(): var address = address_input.text if address.is_empty(): var mode = network_manager.network_mode - if mode == 1 or mode == 2: # WebRTC or WebSocket + if mode == 1 or mode == 2: # WebRTC or WebSocket LogManager.log("Error: Please enter a room code", LogManager.CATEGORY_UI) return - else: # ENet mode without address - use default + else: # ENet mode without address - use default address = "127.0.0.1" var local_count = int(local_players_spinbox.value) @@ -654,11 +706,11 @@ func _on_join_pressed(): if network_manager.join_game(address): last_join_address = address var mode = network_manager.network_mode - if mode == 1: # WebRTC + if mode == 1: # WebRTC LogManager.log("Joining WebRTC game with room code: " + address, LogManager.CATEGORY_UI) - elif mode == 2: # WebSocket + elif mode == 2: # WebSocket LogManager.log("Joining WebSocket game with room code: " + address, LogManager.CATEGORY_UI) - else: # ENet + else: # ENet LogManager.log("Joining ENet game at " + address + " with " + str(local_count) + " local players", LogManager.CATEGORY_UI) func _on_connection_succeeded(): @@ -678,7 +730,16 @@ func _on_connection_succeeded(): if not is_inside_tree(): LogManager.log_error("GameUI: Cannot start game - node not in tree", LogManager.CATEGORY_UI) return - # Use call_deferred to ensure we're in a safe state to change scenes + # Joiner must load game_world immediately so host's spawn RPCs can be processed. Use race from + # GameState (set in _on_race_selected when they picked; do not overwrite). No race select after connect. + var gs = get_node_or_null("/root/GameState") + if gs: + gs.race_chosen_before_connect = false + # Only default to Dwarf if nothing was ever set (e.g. --join --webrtc --race=elf skips UI) + if gs.selected_race.is_empty(): + gs.selected_race = "Dwarf" + print("GameUI: Connection succeeded, starting game - GameState.selected_race = '", gs.selected_race if gs else "Dwarf", "' (joiner will use this for their player)") + LogManager.log("GameUI: Connection succeeded, starting game (race: " + (gs.selected_race if gs else "Dwarf") + ")", LogManager.CATEGORY_UI) call_deferred("_start_game") func _on_connection_failed(): @@ -757,7 +818,7 @@ func _show_connection_error(message: String): if vbox: # Insert after title (index 0) or at the beginning vbox.add_child(connection_error_label) - vbox.move_child(connection_error_label, 1) # Move to position 1 (after title) + vbox.move_child(connection_error_label, 1) # Move to position 1 (after title) # Auto-hide after 5 seconds await get_tree().create_timer(5.0).timeout @@ -769,15 +830,83 @@ func _hide_connection_error(): connection_error_label.queue_free() connection_error_label = null +func _on_select_race_pressed(): + """Joiner picks race before connecting. Show race select; on confirm we just store choice and return to menu.""" + _race_select_standalone = true + _show_race_select_ui() + +func _show_race_select(): + """Show race select before dungeon (after Host or before Join). User picks race; on confirm we start game.""" + if not is_inside_tree(): + return + var gs = get_node_or_null("/root/GameState") + if gs and gs.skip_race_select: + LogManager.log("GameUI: Skipping race select (race from args: " + gs.selected_race + ")", LogManager.CATEGORY_UI) + _start_game() + return + _race_select_standalone = false + _show_race_select_ui() + +func _show_race_select_ui(): + """Show race select overlay. _race_select_standalone: true = joiner picking before Join (don't start game).""" + if not is_inside_tree(): + return + if select_class_instance and is_instance_valid(select_class_instance): + return + if main_menu: + main_menu.visible = false + var sel = select_class_scene.instantiate() + add_child(sel) + select_class_instance = sel + if sel.has_signal("race_selected"): + sel.race_selected.connect(_on_race_selected) + LogManager.log("GameUI: Race select shown" + (" (choose before Join)" if _race_select_standalone else ""), LogManager.CATEGORY_UI) + +func _on_race_selected(race_name: String): + """User confirmed race. Standalone = joiner chose before Join: store and return to menu. Else start game.""" + if select_class_instance and is_instance_valid(select_class_instance): + select_class_instance.queue_free() + select_class_instance = null + # Always persist chosen race to GameState (select_class sets it too; this guarantees it from signal) + var gs = get_node_or_null("/root/GameState") + if gs and not race_name.is_empty(): + gs.selected_race = race_name + LogManager.log("GameUI: JOINER PICKED race='" + race_name + "', GameState.selected_race set to '" + gs.selected_race + "'", LogManager.CATEGORY_UI) + if _race_select_standalone: + _race_select_standalone = false + if gs: + gs.race_chosen_before_connect = true + if pending_auto_join_after_race_select: + # --join --webrtc with no --race=: they just chose race, now start auto-join + pending_auto_join_after_race_select = false + _start_pending_auto_join() + LogManager.log("GameUI: Race chosen (" + race_name + "), fetching rooms to auto-join...", LogManager.CATEGORY_UI) + else: + if main_menu: + main_menu.visible = true + LogManager.log("GameUI: Race chosen for joining (" + race_name + "). Press Join when ready.", LogManager.CATEGORY_UI) + return + _start_game() + func _start_game(): # Check if node is still in the tree before trying to access get_tree() if not is_inside_tree(): LogManager.log_error("GameUI: Cannot change scene - node is not in tree", LogManager.CATEGORY_UI) return - # Hide menu + # Disconnect network callbacks so we don't get signals on freed game_ui after scene change + if network_manager: + if network_manager.connection_succeeded.is_connected(_on_connection_succeeded): + network_manager.connection_succeeded.disconnect(_on_connection_succeeded) + if network_manager.connection_failed.is_connected(_on_connection_failed): + network_manager.connection_failed.disconnect(_on_connection_failed) + + # Hide menu (and race select if still present) if main_menu: main_menu.visible = false + if select_class_instance and is_instance_valid(select_class_instance): + select_class_instance.queue_free() + select_class_instance = null # Load the game scene var tree = get_tree() diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 442d22e..5df973f 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -17,20 +17,20 @@ var screenshake_strength: float = 0.0 var local_players = [] const BASE_CAMERA_ZOOM: float = 4.0 -const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices +const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices const REFERENCE_ASPECT: float = 16.0 / 9.0 # Mouse cursor system -var cursor_sprite: Sprite2D = null # Free movement cursor (frame 0) -var grid_cursor_sprite: Sprite2D = null # Grid-locked cursor (frame 1) -var spell_cursor_sprite: Sprite2D = null # Spell targeting cursor (frame 1, tinted by element) +var cursor_sprite: Sprite2D = null # Free movement cursor (frame 0) +var grid_cursor_sprite: Sprite2D = null # Grid-locked cursor (frame 1) +var spell_cursor_sprite: Sprite2D = null # Spell targeting cursor (frame 1, tinted by element) var cursor_layer: CanvasLayer = null -const CURSOR_LAYER_Z: int = 2000 # Very high Z index for cursor -var use_mouse_control: bool = true # Enable/disable mouse control -var camera_lerp_to_cursor: bool = false # Optional: lerp camera slightly toward cursor -const CURSOR_CAMERA_LERP_AMOUNT: float = 0.15 # How much camera lerps toward cursor (0.0 = none, 1.0 = full) -var cursor_pulse_time: float = 0.0 # Time accumulator for pulsing animation -const CURSOR_PULSE_SPEED: float = 3.0 # Speed of color pulse animation +const CURSOR_LAYER_Z: int = 2000 # Very high Z index for cursor +var use_mouse_control: bool = true # Enable/disable mouse control +var camera_lerp_to_cursor: bool = false # Optional: lerp camera slightly toward cursor +const CURSOR_CAMERA_LERP_AMOUNT: float = 0.15 # How much camera lerps toward cursor (0.0 = none, 1.0 = full) +var cursor_pulse_time: float = 0.0 # Time accumulator for pulsing animation +const CURSOR_PULSE_SPEED: float = 3.0 # Speed of color pulse animation # Fog of war const FOG_TILE_SIZE: int = 16 @@ -39,37 +39,37 @@ const FOG_BACK_RANGE_TILES: float = 3.0 const FOG_RAY_STEP: float = 0.5 const FOG_RAY_ANGLE_STEP: int = 10 const FOG_UPDATE_INTERVAL: float = 0.1 -const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag +const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag const FOG_DEBUG_DRAW: bool = false var fog_update_timer: float = 0.0 var peer_cleanup_timer: float = 0.0 -const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds +const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds # Tab visibility and buffer overflow protection var was_tab_visible: bool = true var tab_inactive_time: float = 0.0 -var last_tab_state_change: int = 0 # Time of last tab state change (for debouncing) -const TAB_STATE_DEBOUNCE_MS: int = 500 # Debounce tab state changes (500ms) +var last_tab_state_change: int = 0 # Time of last tab state change (for debouncing) +const TAB_STATE_DEBOUNCE_MS: int = 500 # Debounce tab state changes (500ms) # Entrance walk-out system -var players_walking_out: Array = [] # Players currently walking out of entrance -var entrance_walk_out_complete: bool = false # True when all players have exited entrance -var last_sound_play_time: Dictionary = {} # sound_name -> time -const SOUND_RATE_LIMIT: float = 0.05 # Only play same sound every 50ms (20 sounds/second max) -const MAX_BUFFER_SIZE: int = 2 * 1024 * 1024 # 2MB buffer threshold +var players_walking_out: Array = [] # Players currently walking out of entrance +var entrance_walk_out_complete: bool = false # True when all players have exited entrance +var last_sound_play_time: Dictionary = {} # sound_name -> time +const SOUND_RATE_LIMIT: float = 0.05 # Only play same sound every 50ms (20 sounds/second max) +const MAX_BUFFER_SIZE: int = 2 * 1024 * 1024 # 2MB buffer threshold var last_buffer_check_time: float = 0.0 -const BUFFER_CHECK_INTERVAL: float = 0.1 # Check buffer every 100ms -var client_buffer_states: Dictionary = {} # peer_id -> {buffered: int, last_check: float, skip_until: float} -const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.1 # Check client buffers every 100ms (more frequent) -const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 256 # Skip sending if buffer > 256KB (lower threshold to catch earlier) -const CLIENT_BUFFER_SKIP_DURATION: float = 3.0 # Skip sending for 3 seconds if buffer is full (longer duration) +const BUFFER_CHECK_INTERVAL: float = 0.1 # Check buffer every 100ms +var client_buffer_states: Dictionary = {} # peer_id -> {buffered: int, last_check: float, skip_until: float} +const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.1 # Check client buffers every 100ms (more frequent) +const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 256 # Skip sending if buffer > 256KB (lower threshold to catch earlier) +const CLIENT_BUFFER_SKIP_DURATION: float = 3.0 # Skip sending for 3 seconds if buffer is full (longer duration) var fog_node: Node2D = null var cached_corridor_mask: PackedInt32Array = PackedInt32Array() var cached_corridor_rooms: Array = [] var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) var cached_corridor_allowed_room_ids: Dictionary = {} -var was_in_corridor: bool = false # Track previous corridor state to detect transitions -var last_corridor_fog_update: float = 0.0 # Time of last fog update in corridor +var was_in_corridor: bool = false # Track previous corridor state to detect transitions +var last_corridor_fog_update: float = 0.0 # Time of last fog update in corridor # Torch-based CanvasModulate: only recalc on room/corridor transition, lerp over time var _torch_darken_initialized: bool = false @@ -78,7 +78,7 @@ var _torch_darken_last_room_id: String = "" var _torch_darken_target_scale: float = 1.0 var _torch_darken_current_scale: float = 1.0 const _TORCH_DARKEN_LERP_SPEED: float = 4.0 -const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through +const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen) var combined_seen: PackedInt32Array = PackedInt32Array() var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored @@ -92,9 +92,9 @@ var current_level: int = 1 var dungeon_seed: int = 0 # Chunked dungeon sync state (client only) -var dungeon_sync_metadata: Dictionary = {} # map_size, seed, level, host_room -var dungeon_sync_chunks: Dictionary = {} # chunk_idx -> {tile_grid_chunk, grid_chunk} -var dungeon_sync_rooms: Dictionary = {} # start_room, rooms +var dungeon_sync_metadata: Dictionary = {} # map_size, seed, level, host_room +var dungeon_sync_chunks: Dictionary = {} # chunk_idx -> {tile_grid_chunk, grid_chunk} +var dungeon_sync_rooms: Dictionary = {} # start_room, rooms var dungeon_sync_total_chunks: int = 0 var dungeon_sync_received_chunks: int = 0 var dungeon_sync_complete: bool = false @@ -115,16 +115,16 @@ var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool var dungeon_chunk_acks: Dictionary = {} # peer_id -> {chunk_idx -> bool, next_chunk_to_send -> int, chunks_data -> Array} # Pre-packed dungeon blob (server only) - packed once, sent many times -var dungeon_blob_chunks: Array = [] # Array of PackedByteArray chunks (<16KB each) -var dungeon_blob_metadata: Dictionary = {} # Static metadata: seed, level, map_size (host_room collected dynamically) +var dungeon_blob_chunks: Array = [] # Array of PackedByteArray chunks (<16KB each) +var dungeon_blob_metadata: Dictionary = {} # Static metadata: seed, level, map_size (host_room collected dynamically) # Level complete tracking -var level_complete_triggered: bool = false # Prevent multiple level complete triggers -var game_over_triggered: bool = false # Prevent multiple game over triggers +var level_complete_triggered: bool = false # Prevent multiple level complete triggers +var game_over_triggered: bool = false # Prevent multiple game over triggers # Server-authoritative "all dead" respawn: avoid host/joiner desync (only joiner or only host respawning) signal respawn_all_ready -var dead_players: Dictionary = {} # player_name -> true; server only +var dead_players: Dictionary = {} # player_name -> true; server only var respawn_all_check_running: bool = false # Track broken interactable objects (object_index -> true) for syncing to new clients @@ -236,7 +236,7 @@ func _send_gameworld_ready(): if not is_peer_in_list and not matchbox_connected: # Retry - on web, this can take several seconds var retry_count = get_meta("gameworld_ready_retry_count", 0) - if retry_count < 50: # Try up to 50 times (10 seconds total) + if retry_count < 50: # Try up to 50 times (10 seconds total) set_meta("gameworld_ready_retry_count", retry_count + 1) LogManager.log("GameWorld: Host peer (1) not in peers and Matchbox not connected yet, retrying (" + str(retry_count + 1) + "/50)...", LogManager.CATEGORY_NETWORK) get_tree().create_timer(0.2).timeout.connect(func(): @@ -417,7 +417,7 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_ # If peer not recognized and Matchbox not connected, retry if not is_peer_recognized and not matchbox_connected: - if retry_count < 15: # Try up to 15 times (3 seconds total) - reduced from 30 + if retry_count < 15: # Try up to 15 times (3 seconds total) - reduced from 30 LogManager.log("GameWorld: Peer " + str(peer_id) + " not recognized yet, retrying sync (" + str(retry_count + 1) + "/15)...", LogManager.CATEGORY_NETWORK) get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -442,6 +442,12 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_ if is_inside_tree(): _rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count]) ) + # Push existing players' state (equipment, inventory, race, appearance) to the new joiner + # so they see the host's Wizard inventory (tomes, etc.). Delay so joiner has spawned player nodes first. + get_tree().create_timer(0.4).timeout.connect(func(): + if is_inside_tree(): + _push_existing_players_state_to_client(peer_id) + ) # Sync broken interactable objects to the new client # Wait a bit after dungeon sync to ensure objects are spawned first @@ -499,6 +505,21 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_ _sync_interactable_object_positions_to_client(peer_id) ) +func _push_existing_players_state_to_client(peer_id: int) -> void: + # Server: push each server-authority player's full state (equipment, inventory, race, appearance) to the new joiner. + # Joiner otherwise never receives host's inventory because _sync_inventory was sent before joiner was "ready". + if not multiplayer.is_server(): + return + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var server_id = multiplayer.get_unique_id() + for child in entities_node.get_children(): + if child.is_in_group("player") and child.get_multiplayer_authority() == server_id: + if child.has_method("_push_full_state_to_peer"): + child._push_full_state_to_peer(peer_id) + LogManager.log("GameWorld: Pushed existing players state to client " + str(peer_id), LogManager.CATEGORY_NETWORK) + func _rpc_to_ready_peers(method: String, args: Array = []): # Send RPC to all clients whose GameWorld is ready if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): @@ -540,7 +561,7 @@ func _rpc_to_ready_peers(method: String, args: Array = []): if connection_obj != null and typeof(connection_obj) == TYPE_INT: var connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED client_gameworld_ready.erase(peer_id) continue @@ -555,12 +576,12 @@ func _rpc_to_ready_peers(method: String, args: Array = []): if channel.has_method("get_ready_state"): var ready_state = channel.get_ready_state() # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN all_channels_open = false break elif "ready_state" in channel: var ready_state = channel.get("ready_state") - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN all_channels_open = false break if not all_channels_open: @@ -575,13 +596,13 @@ func _rpc_to_ready_peers(method: String, args: Array = []): if pc and pc.has_method("get_connection_state"): var matchbox_conn_state = pc.get_connection_state() # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED client_gameworld_ready.erase(peer_id) continue # Check if client's buffer is full - skip sending if so if _should_skip_client_due_to_buffer(peer_id): - continue # Skip sending to this client - their buffer is full + continue # Skip sending to this client - their buffer is full # All checks passed, send RPC # Note: Even with all checks, there's still a tiny race condition window, @@ -641,7 +662,7 @@ func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): if connection_obj != null and typeof(connection_obj) == TYPE_INT: var connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED client_gameworld_ready.erase(peer_id) continue @@ -656,12 +677,12 @@ func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): if channel.has_method("get_ready_state"): var ready_state = channel.get_ready_state() # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN all_channels_open = false break elif "ready_state" in channel: var ready_state = channel.get("ready_state") - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN all_channels_open = false break if not all_channels_open: @@ -676,13 +697,13 @@ func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): if pc and pc.has_method("get_connection_state"): var matchbox_conn_state = pc.get_connection_state() # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED client_gameworld_ready.erase(peer_id) continue # Check if client's buffer is full - skip sending if so (CRITICAL to prevent buffer overflow errors) if _should_skip_client_due_to_buffer(peer_id): - continue # Skip sending to this client - their buffer is full + continue # Skip sending to this client - their buffer is full # All checks passed, send RPC # Note: Even with all checks, there's still a tiny race condition window, @@ -726,7 +747,7 @@ func _is_peer_connected(peer_id: int) -> bool: if typeof(connection_obj) == TYPE_INT: connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED return false else: # Peer not found in WebRTC peer list @@ -809,7 +830,7 @@ func _cleanup_disconnected_peers(): if connection_obj != null and typeof(connection_obj) == TYPE_INT: var connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED peers_to_remove.append(peer_id) continue @@ -825,7 +846,7 @@ func _cleanup_disconnected_peers(): elif "ready_state" in channel: ready_state = channel.get("ready_state") # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN all_channels_open = false break if not all_channels_open: @@ -840,7 +861,7 @@ func _cleanup_disconnected_peers(): if pc and pc.has_method("get_connection_state"): var matchbox_conn_state = pc.get_connection_state() # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED peers_to_remove.append(peer_id) continue @@ -1044,7 +1065,7 @@ func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initi entities_node.add_child(loot) loot.global_position = spawn_position loot.loot_type = loot.LootType.ITEM - loot.item = item # Set the item instance + loot.item = item # Set the item instance # Set initial velocity before _ready() processes # Use synced velocity to ensure bounce matches server loot.velocity = initial_velocity @@ -1270,7 +1291,7 @@ func _sync_enemy_death(enemy_name: String, enemy_index: int): # Skip processing if tab just became active (may be stale messages from when tab was inactive) if not was_tab_visible: - return # Tab was inactive, skip old/stale RPCs to prevent sound spam + return # Tab was inactive, skip old/stale RPCs to prevent sound spam var entities_node = get_node_or_null("Entities") if not entities_node: @@ -1304,7 +1325,7 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou # Skip processing if tab just became active (may be stale messages) if not was_tab_visible: - return # Tab was inactive, skip old/stale RPCs + return # Tab was inactive, skip old/stale RPCs var entities_node = get_node_or_null("Entities") if not entities_node: @@ -1388,6 +1409,38 @@ func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, at # This is okay, just log it print("GameWorld: Could not find enemy for attack sync: name=", enemy_name, " index=", enemy_index) +@rpc("authority", "reliable") +func _sync_enemy_throw_bomb(bomb_name: String, enemy_name: String, enemy_index: int, bomb_pos: Vector2, throw_force: Vector2): + # Clients spawn bomb when server tells them an enemy threw one + if multiplayer.is_server(): + return # Server ignores this (it's the sender) + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var enemy = null + for child in entities_node.get_children(): + if child.is_in_group("enemy"): + if child.name == enemy_name: + enemy = child + break + elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index: + enemy = child + break + if not enemy: + return + var attack_bomb_scene = load("res://scenes/attack_bomb.tscn") + if not attack_bomb_scene: + push_error("GameWorld: Could not load attack_bomb scene for enemy throw sync!") + return + var bomb = attack_bomb_scene.instantiate() + bomb.name = bomb_name + entities_node.add_child(bomb) + bomb.global_position = bomb_pos + bomb.setup(bomb_pos, enemy, throw_force, true) # true = is_thrown + if bomb.has_node("Sprite2D"): + bomb.get_node("Sprite2D").visible = true + print("GameWorld: (client) synced enemy bomb: ", bomb_name, " at ", bomb_pos) + @rpc("any_peer", "reliable") func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_peer_id: int): # Server receives item drop request from client @@ -1463,11 +1516,116 @@ func _request_enemy_damage(enemy_name: String, enemy_index: int, damage: float, if enemy and enemy.has_method("rpc_take_damage"): # Call the enemy's rpc_take_damage method directly (it will handle authority check) - enemy.rpc_take_damage(damage, attacker_position, is_critical, false, false) # is_burn_damage=false, apply_burn_debuff=false + enemy.rpc_take_damage(damage, attacker_position, is_critical, false, false) # is_burn_damage=false, apply_burn_debuff=false else: # Enemy not found - might already be freed print("GameWorld: Could not find enemy for damage request: name=", enemy_name, " index=", enemy_index) +@rpc("any_peer", "reliable") +func _request_punch_hit_effect(pos_x: float, pos_y: float): + # Server receives request from client (punch hit); broadcast effect to all peers + if not multiplayer.is_server(): + return + _sync_punch_hit_effect(pos_x, pos_y) + if has_method("_rpc_to_ready_peers"): + _rpc_to_ready_peers("_sync_punch_hit_effect", [pos_x, pos_y]) + +@rpc("any_peer", "reliable") +func _sync_punch_hit_effect(pos_x: float, pos_y: float): + # Spawn punch hit effect on this peer (called by server or when server broadcasts) + var scene = load("res://scenes/damage_effect_punch.tscn") as PackedScene + if not scene: + return + var effect = scene.instantiate() + add_child(effect) + effect.global_position = Vector2(pos_x, pos_y) + if effect.has_method("setup"): + effect.setup(Vector2(pos_x, pos_y)) + +@rpc("any_peer", "reliable") +func _request_slash_hit_effect(pos_x: float, pos_y: float): + if not multiplayer.is_server(): + return + _sync_slash_hit_effect(pos_x, pos_y) + if has_method("_rpc_to_ready_peers"): + _rpc_to_ready_peers("_sync_slash_hit_effect", [pos_x, pos_y]) + +@rpc("any_peer", "reliable") +func _sync_slash_hit_effect(pos_x: float, pos_y: float): + var scene = load("res://scenes/damage_effect_slash.tscn") as PackedScene + if not scene: + return + var effect = scene.instantiate() + add_child(effect) + effect.global_position = Vector2(pos_x, pos_y) + if effect.has_method("setup"): + effect.setup(Vector2(pos_x, pos_y)) + +@rpc("any_peer", "reliable") +func _request_arrow_hit_effect(pos_x: float, pos_y: float): + if not multiplayer.is_server(): + return + _sync_arrow_hit_effect(pos_x, pos_y) + if has_method("_rpc_to_ready_peers"): + _rpc_to_ready_peers("_sync_arrow_hit_effect", [pos_x, pos_y]) + +@rpc("any_peer", "reliable") +func _sync_arrow_hit_effect(pos_x: float, pos_y: float): + var scene = load("res://scenes/damage_effect_arrow.tscn") as PackedScene + if not scene: + return + var effect = scene.instantiate() + add_child(effect) + effect.global_position = Vector2(pos_x, pos_y) + if effect.has_method("setup"): + effect.setup(Vector2(pos_x, pos_y)) + +@rpc("any_peer", "reliable") +func _request_axe_hit_effect(pos_x: float, pos_y: float): + if not multiplayer.is_server(): + return + _sync_axe_hit_effect(pos_x, pos_y) + if has_method("_rpc_to_ready_peers"): + _rpc_to_ready_peers("_sync_axe_hit_effect", [pos_x, pos_y]) + +@rpc("any_peer", "reliable") +func _sync_axe_hit_effect(pos_x: float, pos_y: float): + var scene = load("res://scenes/damage_effect_axe.tscn") as PackedScene + if not scene: + return + var effect = scene.instantiate() + add_child(effect) + effect.global_position = Vector2(pos_x, pos_y) + if effect.has_method("setup"): + effect.setup(Vector2(pos_x, pos_y)) + +@rpc("any_peer", "reliable") +func _request_bite_effect(player_name: String): + if not multiplayer.is_server(): + return + _sync_bite_effect(player_name) + if has_method("_rpc_to_ready_peers"): + _rpc_to_ready_peers("_sync_bite_effect", [player_name]) + +@rpc("any_peer", "reliable") +func _sync_bite_effect(player_name: String): + var player = player_manager.get_node_or_null(player_name) if player_manager else null + if not player and is_instance_valid(self): + for p in get_tree().get_nodes_in_group("player"): + if p.name == player_name: + player = p + break + if not player or not is_instance_valid(player): + return + var scene = load("res://scenes/damage_effect_bite.tscn") as PackedScene + if not scene: + return + var effect = scene.instantiate() + player.add_child(effect) + effect.position = Vector2.ZERO + if effect.has_method("setup"): + effect.setup(Vector2.ZERO) + @rpc("any_peer", "reliable") func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: int): # Server receives loot pickup request from client @@ -1530,7 +1688,7 @@ func _request_chest_open_by_name(chest_name: String, player_peer_id: int): func _sync_player_exit_stairs(player_peer_id: int): # Client receives notification that a player reached exit stairs if multiplayer.is_server(): - return # Server ignores this (it's the sender) + return # Server ignores this (it's the sender) # Find the player by peer ID var players = get_tree().get_nodes_in_group("player") @@ -1575,11 +1733,24 @@ func _sync_hide_level_complete(): if level_complete_ui: level_complete_ui.visible = false +@rpc("authority", "reliable") +func _sync_disable_stairs(): + # Clients receive stairs disable sync from server + if multiplayer.is_server(): + return # Server ignores this (it's the sender) + + # Disable stairs Area2D on clients so no player can activate it + var stairs_area = get_node_or_null("StairsArea") + if stairs_area: + stairs_area.set_deferred("monitoring", false) + stairs_area.set_deferred("monitorable", false) + LogManager.log("GameWorld: Client disabled stairs Area2D", LogManager.CATEGORY_DUNGEON) + @rpc("authority", "reliable") func _sync_restore_player_controls(): # Clients receive restore controls/collision sync from server if multiplayer.is_server(): - return # Server ignores this (it's the sender) + return # Server ignores this (it's the sender) # Restore controls and collision for local player var my_peer_id = multiplayer.get_unique_id() @@ -1595,7 +1766,7 @@ func _sync_restore_player_controls(): func _sync_remove_black_fade(): # Clients receive remove black fade sync from server if multiplayer.is_server(): - return # Server ignores this (it's the sender) + return # Server ignores this (it's the sender) _remove_black_fade_overlay() @@ -1665,7 +1836,7 @@ func _check_tab_visibility(): return false return window_visible - return true # Always visible on non-web platforms + return true # Always visible on non-web platforms func _process(delta): # Update mouse cursor @@ -1751,7 +1922,7 @@ func _check_client_buffers(current_time: float): var buffer_state = client_buffer_states[peer_id] if current_time - buffer_state.last_check < CLIENT_BUFFER_CHECK_INTERVAL: - continue # Skip this check - too soon + continue # Skip this check - too soon buffer_state.last_check = current_time @@ -1797,7 +1968,7 @@ func _check_and_handle_buffer_overflow(): return var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer - var server_peer_id = 1 # Server is always peer 1 + var server_peer_id = 1 # Server is always peer 1 if not webrtc_peer.has_peer(server_peer_id): return @@ -1888,9 +2059,9 @@ func _update_camera(): func add_screenshake(strength: float, duration: float): # Add screenshake effect - screenshake_strength = max(screenshake_strength, strength) # Use max if already shaking - screenshake_duration = max(screenshake_duration, duration) # Use max duration - screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger + screenshake_strength = max(screenshake_strength, strength) # Use max if already shaking + screenshake_duration = max(screenshake_duration, duration) # Use max duration + screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger func _init_mouse_cursor(): # Create cursor layer with high Z index @@ -1912,8 +2083,8 @@ func _init_mouse_cursor(): # Set up sprite sheet (32x16 with 2 frames of 16x16 each) cursor_sprite.hframes = 2 cursor_sprite.vframes = 1 - cursor_sprite.frame = 0 # Frame 0 = free movement - cursor_sprite.modulate.a = 0.75 # 75% opacity + cursor_sprite.frame = 0 # Frame 0 = free movement + cursor_sprite.modulate.a = 0.75 # 75% opacity cursor_layer.add_child(cursor_sprite) # Create grid-locked cursor sprite (frame 1) @@ -1922,8 +2093,8 @@ func _init_mouse_cursor(): grid_cursor_sprite.texture = cursor_texture grid_cursor_sprite.hframes = 2 grid_cursor_sprite.vframes = 1 - grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked - grid_cursor_sprite.modulate.a = 0.3 # 30% opacity + grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked + grid_cursor_sprite.modulate.a = 0.3 # 30% opacity cursor_layer.add_child(grid_cursor_sprite) # Create spell targeting cursor sprite (frame 1, will be tinted by element) @@ -1932,9 +2103,9 @@ func _init_mouse_cursor(): spell_cursor_sprite.texture = cursor_texture spell_cursor_sprite.hframes = 2 spell_cursor_sprite.vframes = 1 - spell_cursor_sprite.frame = 1 # Frame 1 = grid-locked - spell_cursor_sprite.modulate.a = 0.5 # 50% opacity - spell_cursor_sprite.visible = false # Hidden by default + spell_cursor_sprite.frame = 1 # Frame 1 = grid-locked + spell_cursor_sprite.modulate.a = 0.5 # 50% opacity + spell_cursor_sprite.visible = false # Hidden by default cursor_layer.add_child(spell_cursor_sprite) # Hide system cursor @@ -1975,7 +2146,7 @@ func _update_mouse_cursor(delta: float): # Scale cursors to match camera zoom level (so grid-locked cursor aligns with tiles) # Each cursor frame is 16x16 pixels, and tiles are 16x16 pixels # Scale by camera zoom to maintain 1:1 pixel ratio - var cursor_scale = camera.zoom.x # Use x zoom (should be same as y) + var cursor_scale = camera.zoom.x # Use x zoom (should be same as y) cursor_sprite.scale = Vector2.ONE * cursor_scale grid_cursor_sprite.scale = Vector2.ONE * cursor_scale spell_cursor_sprite.scale = Vector2.ONE * cursor_scale @@ -1986,7 +2157,7 @@ func _update_mouse_cursor(delta: float): if dungeon_tilemap_layer: var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos) - if tile_data >= 0: # Valid tile + if tile_data >= 0: # Valid tile show_grid_cursor = true # Snap to tile center for world position calculation grid_locked_world_pos = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position @@ -1997,7 +2168,7 @@ func _update_mouse_cursor(delta: float): var grid_locked_screen_pos = (grid_locked_world_pos - camera.position) * camera.zoom.x + viewport_center grid_cursor_sprite.position = grid_locked_screen_pos else: - grid_cursor_sprite.position = Vector2(-1000, -1000) # Hide off-screen + grid_cursor_sprite.position = Vector2(-1000, -1000) # Hide off-screen # Update free cursor position (always follows mouse) cursor_sprite.position = mouse_pos @@ -2025,21 +2196,21 @@ func _update_mouse_cursor(delta: float): # Tint by element match spell_element: "fire": - spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Red + spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Red "healing": - spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.35, 0.5) # Green + spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.35, 0.5) # Green "frost": - spell_cursor_sprite.modulate = Color(0.3, 0.6, 1.0, 0.5) # Blue + spell_cursor_sprite.modulate = Color(0.3, 0.6, 1.0, 0.5) # Blue "water", "ice": - spell_cursor_sprite.modulate = Color(0.3, 0.5, 1.0, 0.5) # Blue + spell_cursor_sprite.modulate = Color(0.3, 0.5, 1.0, 0.5) # Blue "electric": - spell_cursor_sprite.modulate = Color(1.0, 1.0, 0.3, 0.5) # Yellow + spell_cursor_sprite.modulate = Color(1.0, 1.0, 0.3, 0.5) # Yellow "earth": - spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.3, 0.5) # Green + spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.3, 0.5) # Green "wind": - spell_cursor_sprite.modulate = Color(1.0, 1.0, 1.0, 0.5) # White + spell_cursor_sprite.modulate = Color(1.0, 1.0, 1.0, 0.5) # White _: - spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Default red + spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Default red else: spell_cursor_sprite.visible = false else: @@ -2048,12 +2219,12 @@ func _update_mouse_cursor(delta: float): grid_cursor_sprite.visible = show_grid_cursor if show_grid_cursor: # Pulse color: oscillate between normal and brighter color - var pulse_value = (sin(cursor_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + var pulse_value = (sin(cursor_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 # Interpolate between normal (1,1,1) and brighter (1.5, 1.2, 1.0) for a warm pulse var base_color = Color(1.0, 1.0, 1.0) var pulse_color = Color(1.5, 1.2, 1.0) - grid_cursor_sprite.modulate = base_color.lerp(pulse_color, pulse_value * 0.5) # 50% of the way to pulse color - grid_cursor_sprite.modulate.a = 0.3 # Keep opacity at 30% + grid_cursor_sprite.modulate = base_color.lerp(pulse_color, pulse_value * 0.5) # 50% of the way to pulse color + grid_cursor_sprite.modulate.a = 0.3 # Keep opacity at 30% # Update player facing direction based on mouse position (use world position) # Only update if mouse is inside the window viewport @@ -2061,7 +2232,7 @@ func _update_mouse_cursor(delta: float): var mouse_in_window = viewport_rect.has_point(mouse_pos) if local_players.size() > 0: - var player = local_players[0] # Use first local player + var player = local_players[0] # Use first local player if player and is_instance_valid(player) and player.is_local_player: if mouse_in_window: # Mouse is in window - use mouse for direction control @@ -2098,11 +2269,11 @@ func get_grid_locked_cursor_position() -> Vector2: var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos) - if tile_data >= 0: # Valid tile + if tile_data >= 0: # Valid tile # Return tile center world position return dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position - return Vector2.ZERO # No valid grid position + return Vector2.ZERO # No valid grid position func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2: # Get valid spell target position (closest valid floor tile, or in front of wall if blocked) @@ -2128,7 +2299,7 @@ func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2: # If target is invalid, find closest valid position along the line from player to target var direction = (target_world_pos - player_pos).normalized() var max_distance = player_pos.distance_to(target_world_pos) - var step_size = 16.0 # One tile + var step_size = 16.0 # One tile var steps = int(max_distance / step_size) + 1 # Search backwards from target towards player to find first valid position @@ -2144,7 +2315,7 @@ func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2: if _is_valid_spell_target(check_tile_center, player_pos): return check_tile_center - return Vector2.ZERO # No valid position found + return Vector2.ZERO # No valid position found func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool: # Check if target position is valid for spell casting (floor tile, not blocked by wall) @@ -2171,7 +2342,7 @@ func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool: var query = PhysicsRayQueryParameters2D.new() query.from = player_pos query.to = target_pos - query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) + query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) # Exclude player if we have a reference if local_players.size() > 0: @@ -2322,8 +2493,8 @@ func _update_fog_of_war(delta: float) -> void: # Only update if player moved significantly OR enough time has passed (much longer interval) var time_since_last_update = Time.get_ticks_msec() / 1000.0 - last_corridor_fog_update - if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently - return # Skip expensive fog update - player is stationary in corridor + if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently + return # Skip expensive fog update - player is stationary in corridor var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL fog_update_timer += delta @@ -2364,9 +2535,9 @@ func _update_fog_of_war(delta: float) -> void: continue var tile_room = _find_room_at_tile(Vector2i(x, y)) if tile_room.is_empty(): - continue # Corridor: keep (already revealed by raycast if visible) + continue # Corridor: keep (already revealed by raycast if visible) if tile_room.x != current_room.x or tile_room.y != current_room.y or tile_room.w != current_room.w or tile_room.h != current_room.h: - combined_seen[idx] = 0 # Other room: hide + combined_seen[idx] = 0 # Other room: hide else: # In corridors (no room), only show tiles connected to the corridor component # AND explicitly clear combined_seen for all tiles in rooms that aren't connected @@ -2747,7 +2918,7 @@ func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array: queue.append(start_tile) mask[start_tile.x + start_tile.y * map_size.x] = 1 - var max_steps = 10 # Reduced from 24 to prevent corridor branches from reaching far rooms + var max_steps = 10 # Reduced from 24 to prevent corridor branches from reaching far rooms while queue.size() > 0: var tile = queue.pop_front() var dist = abs(tile.x - start_tile.x) + abs(tile.y - start_tile.y) @@ -2856,10 +3027,24 @@ func _generate_dungeon(): # Reset level complete flag for new level level_complete_triggered = false - game_over_triggered = false # Reset game over flag for new level + game_over_triggered = false # Reset game over flag for new level dead_players.clear() respawn_all_check_running = false + # CRITICAL: Reset all players' death state flags when transitioning to a new level + # This prevents GAME OVER from showing incorrectly when players are alive in the new level + var players = get_tree().get_nodes_in_group("player") + for player in players: + if is_instance_valid(player): + if "is_dead" in player: + player.is_dead = false + if "is_processing_death" in player: + player.is_processing_death = false + # Reset exit notification flag for new level + if "has_seen_exit_this_level" in player: + player.has_seen_exit_this_level = false + LogManager.log("GameWorld: Reset death state for player " + str(player.name) + " on level transition", LogManager.CATEGORY_DUNGEON) + # Hide game over UI if it exists var game_over_ui = get_node_or_null("GameOverUI") if game_over_ui: @@ -2960,7 +3145,7 @@ func _generate_dungeon(): # Use blob sync for all connected peers var peers = multiplayer.get_peers() for peer_id in peers: - if peer_id != 1: # Don't sync to self (server is peer 1) + if peer_id != 1: # Don't sync to self (server is peer 1) print("GameWorld: HOST - Syncing new level to peer ", peer_id, " using blob sync") _sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, host_room) @@ -2968,19 +3153,19 @@ func _generate_dungeon(): # Dungeon shader color replacement: 13 original colors (wall x6, ground x5, fallout x2) const _DUNGEON_ORIGINALS: Array = [ - Color(24/255.0, 59/255.0, 255/255.0), # 0 wall - Color(33/255.0, 50/255.0, 195/255.0), # 1 wall - Color(98/255.0, 29/255.0, 93/255.0), # 2 wall - Color(66/255.0, 13/255.0, 52/255.0), # 3 wall - Color(74/255.0, 33/255.0, 134/255.0), # 4 wall - Color(50/255.0, 12/255.0, 23/255.0), # 5 wall - Color(149/255.0, 79/255.0, 111/255.0), # 6 ground - Color(192/255.0, 95/255.0, 193/255.0), # 7 ground - Color(48/255.0, 38/255.0, 20/255.0), # 8 ground - Color(143/255.0, 71/255.0, 112/255.0), # 9 ground - Color(106/255.0, 62/255.0, 57/255.0), # 10 ground - Color(69/255.0, 42/255.0, 31/255.0), # 11 fallout - Color(53/255.0, 46/255.0, 26/255.0), # 12 fallout + Color(24 / 255.0, 59 / 255.0, 255 / 255.0), # 0 wall + Color(33 / 255.0, 50 / 255.0, 195 / 255.0), # 1 wall + Color(98 / 255.0, 29 / 255.0, 93 / 255.0), # 2 wall + Color(66 / 255.0, 13 / 255.0, 52 / 255.0), # 3 wall + Color(74 / 255.0, 33 / 255.0, 134 / 255.0), # 4 wall + Color(50 / 255.0, 12 / 255.0, 23 / 255.0), # 5 wall + Color(149 / 255.0, 79 / 255.0, 111 / 255.0), # 6 ground + Color(192 / 255.0, 95 / 255.0, 193 / 255.0), # 7 ground + Color(48 / 255.0, 38 / 255.0, 20 / 255.0), # 8 ground + Color(143 / 255.0, 71 / 255.0, 112 / 255.0), # 9 ground + Color(106 / 255.0, 62 / 255.0, 57 / 255.0), # 10 ground + Color(69 / 255.0, 42 / 255.0, 31 / 255.0), # 11 fallout + Color(53 / 255.0, 46 / 255.0, 26 / 255.0), # 12 fallout ] # Original wall slots ordered light→dark (by luminance). Tile art uses these for highlight/mid/shadow. @@ -3016,80 +3201,80 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array: var walls: Array var ground_fallout: Array match scheme_index: - 0: # 1️⃣ Arcane Blue (magic / night / mana) + 0: # 1️⃣ Arcane Blue (magic / night / mana) walls = [ - Color(24/255.0, 59/255.0, 255/255.0), Color(80/255.0, 120/255.0, 255/255.0), Color(140/255.0, 180/255.0, 255/255.0), - Color(10/255.0, 30/255.0, 120/255.0), Color(180/255.0, 200/255.0, 255/255.0), Color(220/255.0, 230/255.0, 255/255.0), + Color(24 / 255.0, 59 / 255.0, 255 / 255.0), Color(80 / 255.0, 120 / 255.0, 255 / 255.0), Color(140 / 255.0, 180 / 255.0, 255 / 255.0), + Color(10 / 255.0, 30 / 255.0, 120 / 255.0), Color(180 / 255.0, 200 / 255.0, 255 / 255.0), Color(220 / 255.0, 230 / 255.0, 255 / 255.0), ] ground_fallout = [ Color(0.78, 0.48, 0.24), Color(0.84, 0.54, 0.30), Color(0.58, 0.36, 0.16), Color(0.72, 0.44, 0.22), Color(0.66, 0.40, 0.20), Color(0.38, 0.30, 0.22), Color(0.32, 0.28, 0.20), ] - 1: # 2️⃣ Crimson Void (blood / corruption / danger) + 1: # 2️⃣ Crimson Void (blood / corruption / danger) walls = [ - Color(120/255.0, 20/255.0, 40/255.0), Color(180/255.0, 40/255.0, 60/255.0), Color(220/255.0, 90/255.0, 110/255.0), - Color(60/255.0, 5/255.0, 20/255.0), Color(255/255.0, 140/255.0, 160/255.0), Color(90/255.0, 10/255.0, 30/255.0), + Color(120 / 255.0, 20 / 255.0, 40 / 255.0), Color(180 / 255.0, 40 / 255.0, 60 / 255.0), Color(220 / 255.0, 90 / 255.0, 110 / 255.0), + Color(60 / 255.0, 5 / 255.0, 20 / 255.0), Color(255 / 255.0, 140 / 255.0, 160 / 255.0), Color(90 / 255.0, 10 / 255.0, 30 / 255.0), ] ground_fallout = [ Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), Color(0.22, 0.34, 0.30), ] - 2: # 3️⃣ Toxic Green (poison / nature / alchemy) + 2: # 3️⃣ Toxic Green (poison / nature / alchemy) walls = [ - Color(20/255.0, 120/255.0, 40/255.0), Color(60/255.0, 180/255.0, 90/255.0), Color(120/255.0, 220/255.0, 160/255.0), - Color(10/255.0, 60/255.0, 25/255.0), Color(180/255.0, 255/255.0, 210/255.0), Color(40/255.0, 90/255.0, 55/255.0), + Color(20 / 255.0, 120 / 255.0, 40 / 255.0), Color(60 / 255.0, 180 / 255.0, 90 / 255.0), Color(120 / 255.0, 220 / 255.0, 160 / 255.0), + Color(10 / 255.0, 60 / 255.0, 25 / 255.0), Color(180 / 255.0, 255 / 255.0, 210 / 255.0), Color(40 / 255.0, 90 / 255.0, 55 / 255.0), ] ground_fallout = [ Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56), Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38), Color(0.28, 0.22, 0.32), ] - 3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) + 3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) walls = [ - Color(40/255.0, 40/255.0, 45/255.0), Color(80/255.0, 80/255.0, 85/255.0), Color(130/255.0, 130/255.0, 135/255.0), - Color(20/255.0, 20/255.0, 25/255.0), Color(180/255.0, 180/255.0, 185/255.0), Color(220/255.0, 220/255.0, 225/255.0), + Color(40 / 255.0, 40 / 255.0, 45 / 255.0), Color(80 / 255.0, 80 / 255.0, 85 / 255.0), Color(130 / 255.0, 130 / 255.0, 135 / 255.0), + Color(20 / 255.0, 20 / 255.0, 25 / 255.0), Color(180 / 255.0, 180 / 255.0, 185 / 255.0), Color(220 / 255.0, 220 / 255.0, 225 / 255.0), ] ground_fallout = [ Color(0.50, 0.50, 0.52), Color(0.55, 0.55, 0.57), Color(0.35, 0.35, 0.38), Color(0.48, 0.48, 0.50), Color(0.42, 0.42, 0.45), Color(0.28, 0.28, 0.30), Color(0.24, 0.24, 0.26), ] - 4: # 5️⃣ Royal Purple (arcane royalty / bosses) + 4: # 5️⃣ Royal Purple (arcane royalty / bosses) walls = [ - Color(80/255.0, 30/255.0, 130/255.0), Color(130/255.0, 70/255.0, 180/255.0), Color(180/255.0, 130/255.0, 220/255.0), - Color(40/255.0, 10/255.0, 80/255.0), Color(220/255.0, 180/255.0, 255/255.0), Color(100/255.0, 60/255.0, 150/255.0), + Color(80 / 255.0, 30 / 255.0, 130 / 255.0), Color(130 / 255.0, 70 / 255.0, 180 / 255.0), Color(180 / 255.0, 130 / 255.0, 220 / 255.0), + Color(40 / 255.0, 10 / 255.0, 80 / 255.0), Color(220 / 255.0, 180 / 255.0, 255 / 255.0), Color(100 / 255.0, 60 / 255.0, 150 / 255.0), ] ground_fallout = [ Color(0.90, 0.68, 0.22), Color(0.94, 0.74, 0.28), Color(0.72, 0.52, 0.14), Color(0.84, 0.60, 0.18), Color(0.78, 0.56, 0.16), Color(0.46, 0.36, 0.20), Color(0.38, 0.30, 0.18), ] - 5: # 6️⃣ Desert Gold (sand / temples / sunlight) + 5: # 6️⃣ Desert Gold (sand / temples / sunlight) walls = [ - Color(150/255.0, 110/255.0, 40/255.0), Color(200/255.0, 160/255.0, 80/255.0), Color(240/255.0, 210/255.0, 140/255.0), - Color(90/255.0, 60/255.0, 15/255.0), Color(255/255.0, 230/255.0, 170/255.0), Color(170/255.0, 130/255.0, 60/255.0), + Color(150 / 255.0, 110 / 255.0, 40 / 255.0), Color(200 / 255.0, 160 / 255.0, 80 / 255.0), Color(240 / 255.0, 210 / 255.0, 140 / 255.0), + Color(90 / 255.0, 60 / 255.0, 15 / 255.0), Color(255 / 255.0, 230 / 255.0, 170 / 255.0), Color(170 / 255.0, 130 / 255.0, 60 / 255.0), ] ground_fallout = [ Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), Color(0.22, 0.34, 0.30), ] - 6: # 7️⃣ Neon Cyber (sci-fi / UI / hacking) + 6: # 7️⃣ Ancient Stone (medieval / castles / ruins) walls = [ - Color(20/255.0, 240/255.0, 220/255.0), Color(240/255.0, 60/255.0, 220/255.0), Color(120/255.0, 120/255.0, 255/255.0), - Color(10/255.0, 20/255.0, 40/255.0), Color(255/255.0, 255/255.0, 255/255.0), Color(80/255.0, 255/255.0, 180/255.0), + Color(120 / 255.0, 110 / 255.0, 100 / 255.0), Color(160 / 255.0, 150 / 255.0, 140 / 255.0), Color(200 / 255.0, 190 / 255.0, 180 / 255.0), + Color(60 / 255.0, 55 / 255.0, 50 / 255.0), Color(220 / 255.0, 210 / 255.0, 200 / 255.0), Color(90 / 255.0, 85 / 255.0, 75 / 255.0), ] ground_fallout = [ - Color(0.45, 0.28, 0.55), Color(0.52, 0.35, 0.62), Color(0.38, 0.22, 0.48), - Color(0.48, 0.32, 0.58), Color(0.42, 0.26, 0.52), Color(0.28, 0.18, 0.38), - Color(0.22, 0.14, 0.32), + Color(0.35, 0.28, 0.22), Color(0.40, 0.32, 0.26), Color(0.28, 0.22, 0.18), + Color(0.38, 0.30, 0.24), Color(0.32, 0.26, 0.20), Color(0.24, 0.20, 0.16), + Color(0.20, 0.16, 0.14), ] - 7: # 8️⃣ Infernal Lava (hell / bosses / damage) + 7: # 8️⃣ Infernal Lava (hell / bosses / damage) walls = [ - Color(180/255.0, 40/255.0, 20/255.0), Color(240/255.0, 90/255.0, 30/255.0), Color(255/255.0, 160/255.0, 80/255.0), - Color(90/255.0, 10/255.0, 5/255.0), Color(255/255.0, 210/255.0, 160/255.0), Color(140/255.0, 30/255.0, 15/255.0), + Color(180 / 255.0, 40 / 255.0, 20 / 255.0), Color(240 / 255.0, 90 / 255.0, 30 / 255.0), Color(255 / 255.0, 160 / 255.0, 80 / 255.0), + Color(90 / 255.0, 10 / 255.0, 5 / 255.0), Color(255 / 255.0, 210 / 255.0, 160 / 255.0), Color(140 / 255.0, 30 / 255.0, 15 / 255.0), ] ground_fallout = [ Color(0.32, 0.68, 0.48), Color(0.38, 0.74, 0.54), Color(0.22, 0.52, 0.36), @@ -3664,9 +3849,9 @@ func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, # Check if peer is in get_peers() - this is the only reliable check for RPC readiness if not is_in_peers: # Reduced retries - don't retry so many times for host - var max_retries = 30 if OS.get_name() == "Web" else 20 # 6 seconds on web, 4 seconds otherwise + var max_retries = 30 if OS.get_name() == "Web" else 20 # 6 seconds on web, 4 seconds otherwise if retry_count < max_retries: - if retry_count % 10 == 0: # Log every 10th retry (every 2 seconds) to reduce spam + if retry_count % 10 == 0: # Log every 10th retry (every 2 seconds) to reduce spam # Check data channel states for debugging var channel_info = "" if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: @@ -3686,7 +3871,7 @@ func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, else: # After max retries, give up and log error LogManager.log_error("GameWorld: Max retries (" + str(max_retries) + ") reached for peer " + str(client_peer_id) + ", giving up on dungeon sync", LogManager.CATEGORY_NETWORK) - dungeon_sync_in_progress.erase(client_peer_id) # Clear sync flag + dungeon_sync_in_progress.erase(client_peer_id) # Clear sync flag return LogManager.log("GameWorld: Sending dungeon sync RPC to peer " + str(client_peer_id) + " (is_in_peers: " + str(is_in_peers) + ", is_recognized: " + str(is_recognized) + ")", LogManager.CATEGORY_NETWORK) @@ -3815,7 +4000,7 @@ func _send_dungeon_blob_sync(client_peer_id: int): chunk_acks[i] = false dungeon_chunk_acks[client_peer_id] = { - "chunks": dungeon_blob_chunks.duplicate(), # Copy of chunks + "chunks": dungeon_blob_chunks.duplicate(), # Copy of chunks "acks": chunk_acks, "next_chunk": 0, "total_chunks": total_chunks @@ -3828,10 +4013,10 @@ func _send_dungeon_blob_sync(client_peer_id: int): if not is_inside_tree() or not multiplayer.is_server(): return if not dungeon_chunk_acks.has(pid): - return # Sync completed or already retried + return # Sync completed or already retried var sd = dungeon_chunk_acks[pid] if sd.next_chunk > 0: - return # Already receiving chunk acks, no retry needed + return # Already receiving chunk acks, no retry needed LogManager.log("GameWorld: HOST - No metadata ack from peer " + str(pid) + " after 5s, retrying dungeon sync", LogManager.CATEGORY_NETWORK) dungeon_chunk_acks.erase(pid) dungeon_sync_in_progress.erase(pid) @@ -3848,7 +4033,7 @@ func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, # Check WebRTC data channel buffer before sending large RPC # According to Godot docs, get_buffered_amount() returns bytes queued # Typical buffer size is 16MB, but we should keep it under 1MB to be safe - var max_buffer_size = 1024 * 1024 # 1MB threshold + var max_buffer_size = 1024 * 1024 # 1MB threshold var buffer_ok = true var buffered_amount = 0 @@ -3869,9 +4054,9 @@ func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, if not buffer_ok: # Buffer is too full, wait and retry - var max_retries = 50 # Wait up to 10 seconds (50 * 0.2s) + var max_retries = 50 # Wait up to 10 seconds (50 * 0.2s) if retry_count < max_retries: - if retry_count % 10 == 0: # Log every 2 seconds + if retry_count % 10 == 0: # Log every 2 seconds print("GameWorld: HOST - Buffer too full (", buffered_amount, " bytes), waiting before sending dungeon sync to peer ", client_peer_id, " (retry ", retry_count + 1, "/", max_retries, ")") get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -3905,7 +4090,7 @@ func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, var total_chunks = ceil(float(total_rows) / float(ROWS_PER_CHUNK)) # Calculate actual chunk size for logging - var bytes_per_row = map_size.x * 12 # 8 bytes Vector2i + 4 bytes int + var bytes_per_row = map_size.x * 12 # 8 bytes Vector2i + 4 bytes int var chunk_size_bytes = ROWS_PER_CHUNK * bytes_per_row print("GameWorld: HOST - Chunk calculation: ", map_size.x, " cols * 12 bytes/row = ", bytes_per_row, " bytes/row") print("GameWorld: HOST - 10 rows per chunk = ", chunk_size_bytes, " bytes (~", chunk_size_bytes / 1024.0, "KB) per chunk") @@ -3953,7 +4138,7 @@ func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, dungeon_chunk_acks[client_peer_id] = { "chunks": chunks_data, "acks": chunk_acks, - "next_chunk": 0, # Start with chunk 0 (metadata is sent immediately) + "next_chunk": 0, # Start with chunk 0 (metadata is sent immediately) "total_chunks": total_chunks, "start_room": start_room, "rooms": rooms, @@ -4088,7 +4273,7 @@ func _pack_dungeon_blob(): print("GameWorld: HOST - Packed dungeon blob: ", blob_size, " bytes (", blob_size / 1024.0, "KB)") # Chunk the bytes into <16KB pieces - const MAX_CHUNK_SIZE = 14 * 1024 # 14KB to leave room for overhead + const MAX_CHUNK_SIZE = 14 * 1024 # 14KB to leave room for overhead dungeon_blob_chunks.clear() var offset = 0 @@ -4110,11 +4295,11 @@ func _estimate_chunk_size(tile_grid_chunk: Array, grid_chunk: Array) -> int: var size = 0 for col in tile_grid_chunk: if col is Array: - size += col.size() * 8 # Vector2i = 8 bytes + size += col.size() * 8 # Vector2i = 8 bytes for col in grid_chunk: if col is Array: - size += col.size() * 4 # int = 4 bytes - size += 1024 # Dictionary/Array overhead + size += col.size() * 4 # int = 4 bytes + size += 1024 # Dictionary/Array overhead return size @rpc("authority", "reliable", "call_local") @@ -4331,7 +4516,7 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec pending_chest_opens.clear() for chest_name in opened_chests_list: pending_chest_opens[chest_name] = { - "loot_type": "coin", # Default, actual loot type should be stored if available + "loot_type": "coin", # Default, actual loot type should be stored if available "player_peer_id": 0, "item_data": {} } @@ -4355,6 +4540,22 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec dungeon_data.clear() # Clear any ongoing syncs dungeon_sync_in_progress.clear() + + # CRITICAL: Reset all players' death state flags when transitioning to a new level + # This prevents GAME OVER from showing incorrectly when players are alive in the new level + var players = get_tree().get_nodes_in_group("player") + for player in players: + if is_instance_valid(player): + if "is_dead" in player: + player.is_dead = false + if "is_processing_death" in player: + player.is_processing_death = false + # Reset exit notification flag for new level + if "has_seen_exit_this_level" in player: + player.has_seen_exit_this_level = false + # Also reset game over flag on client + game_over_triggered = false + LogManager.log("GameWorld: Client - Reset death state for all players on level transition", LogManager.CATEGORY_DUNGEON) # CRITICAL: Store defeated enemies, broken objects, opened chests, and door states in temporary variables # because _clear_level() will clear them, but we need them for the new level @@ -4370,7 +4571,7 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec "seed": seed_value, "level": level, "host_room": host_room, - "existing_loot": existing_loot_list # Store for spawning after dungeon is ready + "existing_loot": existing_loot_list # Store for spawning after dungeon is ready } dungeon_sync_chunks.clear() dungeon_sync_received_chunks = 0 @@ -4427,7 +4628,7 @@ func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int print("=== [CHUNK 0] Client received metadata - Level: ", level, ", Map size: ", map_size_sync, " ===") if not multiplayer.is_server(): # Send acknowledgment for metadata (chunk -1) - _ack_dungeon_chunk.rpc_id(1, -1) # Send to server (peer 1) + _ack_dungeon_chunk.rpc_id(1, -1) # Send to server (peer 1) # If we're already syncing and this is for the same level, ignore duplicate metadata if not dungeon_sync_complete and dungeon_sync_metadata.has("level"): var existing_level = dungeon_sync_metadata.get("level", 0) @@ -4456,7 +4657,7 @@ func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int print("GameWorld: Client - [CHUNK 0] Metadata received - Map size: ", map_size_sync, ", Level: ", level) print("GameWorld: Client - [CHUNK 0] Expecting ", dungeon_sync_total_chunks, " tile chunks (", total_rows, " rows total)") - var bytes_per_row = map_size_sync.x * 12 # 8 bytes Vector2i + 4 bytes int + var bytes_per_row = map_size_sync.x * 12 # 8 bytes Vector2i + 4 bytes int var chunk_size_bytes = 10 * bytes_per_row print("GameWorld: Client - [CHUNK 0] Chunk size: ~", chunk_size_bytes / 1024.0, "KB per chunk (", map_size_sync.x, " cols * 12 bytes/row * 10 rows)") LogManager.log("GameWorld: Client received dungeon metadata for level " + str(level) + " (map size: " + str(map_size_sync) + ")", LogManager.CATEGORY_DUNGEON) @@ -4493,7 +4694,7 @@ func _sync_dungeon_blob_chunk(chunk_idx: int, chunk_bytes: PackedByteArray): print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)") # Send acknowledgment to server - _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) + _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) # Check if all chunks received if dungeon_sync_received_chunks >= dungeon_sync_total_chunks: @@ -4519,7 +4720,7 @@ func _sync_dungeon_chunk(chunk_idx: int, start_row: int, end_row: int, tile_grid print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)") # Send acknowledgment to server - _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) + _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) # Check if all chunks received (will be triggered by _sync_dungeon_rooms) @@ -4713,7 +4914,7 @@ func _reassemble_dungeon_blob(): # Update fog of war to reveal area around player (CRITICAL for joiner to see the map) await get_tree().process_frame print("GameWorld: Client - Updating fog of war after player positioning...") - _update_fog_of_war(0.0) # Pass 0.0 for delta since we're in async context + _update_fog_of_war(0.0) # Pass 0.0 for delta since we're in async context print("GameWorld: Client - Fog of war updated") # Load HUD @@ -4736,7 +4937,7 @@ func _reassemble_dungeon_blob(): func _check_and_render_dungeon(): # Assemble dungeon_data from chunks and render if dungeon_sync_complete: - return # Already rendered + return # Already rendered if dungeon_sync_metadata.is_empty(): print("GameWorld: Client - Cannot render: metadata not received") @@ -4899,10 +5100,10 @@ func _fix_player_appearance_after_dungeon_sync(): # This ensures all players (including joiners) have the same appearance across all clients # IMPORTANT: Only run on clients, not on server (server players already have correct appearance) if multiplayer.is_server(): - return # Server players already have correct appearance, skip + return # Server players already have correct appearance, skip if dungeon_seed == 0: - return # No seed yet, skip + return # No seed yet, skip var players = get_tree().get_nodes_in_group("player") for player in players: @@ -5014,7 +5215,7 @@ func _spawn_enemies(): print("GameWorld: [SKIP] Skipping spawn of defeated enemy with index " + str(i) + " (defeated_enemies has this index)") print("GameWorld: [SKIP] Enemy at array index ", i, " will NOT be spawned") LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(i), LogManager.CATEGORY_NETWORK) - continue # Don't spawn defeated enemies + continue # Don't spawn defeated enemies print("GameWorld: [SPAWN] Spawning enemy at index ", i, " (not in defeated_enemies)") @@ -5201,7 +5402,7 @@ func _spawn_traps(): # Set multiplayer authority to server if is_server: - trap.set_multiplayer_authority(1) # Server is authority + trap.set_multiplayer_authority(1) # Server is authority entities_node.add_child(trap, true) LogManager.log("GameWorld: Spawned trap at " + str(trap_data.position), LogManager.CATEGORY_DUNGEON) @@ -5318,8 +5519,8 @@ func _spawn_interactable_objects(): if is_chest and pending_chest_opens.has(obj.name): print("GameWorld: Found pending chest open for ", obj.name, " (object_type: ", obj.object_type, "), applying now") var chest_state = pending_chest_opens[obj.name] - var obj_ref = obj # Capture reference for deferred call - var chest_name = obj.name # Capture name for logging + var obj_ref = obj # Capture reference for deferred call + var chest_name = obj.name # Capture name for logging # Use call_deferred to ensure chest is fully initialized (sprite, chest_opened_frame, etc.) # This happens after setup_chest is called, so sprite and frames should be ready call_deferred("_open_pending_chest", obj_ref, chest_state, chest_name) @@ -5337,8 +5538,8 @@ func _spawn_interactable_objects(): print("GameWorld: Object at index ", i, " (name: ", obj.name, ") is marked as broken, breaking it now") print("GameWorld: Object state - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", has _sync_break: ", obj.has_method("_sync_break"), ", is_destroyable: ", obj.is_destroyable if "is_destroyable" in obj else "N/A") # Use both call_deferred and timer to ensure object is fully initialized - var obj_ref = obj # Capture reference - var obj_index = i # Capture index for logging + var obj_ref = obj # Capture reference + var obj_index = i # Capture index for logging # First try with call_deferred (runs at end of frame) call_deferred("_break_spawned_object", obj_ref, obj_index) # Also use timer as backup (wait longer to ensure sprite and all components are ready) @@ -5369,7 +5570,7 @@ func _break_spawned_object(obj: Node, obj_index: int): if obj.has_method("_sync_break"): print("GameWorld: Breaking object at index ", obj_index, " (name: ", obj.name, ")") print("GameWorld: Object state before break - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", is_queued_for_deletion: ", obj.is_queued_for_deletion(), ", has sprite: ", "sprite" in obj and obj.sprite != null) - obj._sync_break(true) # silent=true to avoid duplicate sounds + obj._sync_break(true) # silent=true to avoid duplicate sounds # Verify it worked if "is_broken" in obj: print("GameWorld: Object state after _sync_break - is_broken: ", obj.is_broken) @@ -5437,7 +5638,7 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): # Check if this enemy was already defeated (for clients joining mid-game) if enemy_index >= 0 and defeated_enemies.has(enemy_index): LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_NETWORK) - return # Don't spawn defeated enemies + return # Don't spawn defeated enemies if not multiplayer.is_server(): # Convert enemy type to full path if needed (same as _spawn_enemies) @@ -5535,7 +5736,7 @@ func _sync_existing_chest_states_to_client(client_peer_id: int, retry_count: int # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - if retry_count < 15: # Reduced from 30 + if retry_count < 15: # Reduced from 30 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_chest_states_to_client(client_peer_id, retry_count + 1) @@ -5569,7 +5770,7 @@ func _sync_existing_trap_states_to_client(client_peer_id: int, retry_count: int # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - if retry_count < 15: # Reduced from 30 + if retry_count < 15: # Reduced from 30 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_trap_states_to_client(client_peer_id, retry_count + 1) @@ -5630,6 +5831,39 @@ func _apply_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed trap.sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) if "activation_area" in trap and trap.activation_area: trap.activation_area.monitoring = false + + # Grant EXP to all players when trap is disarmed (only on server) + if multiplayer.is_server(): + var trap_exp_reward = 8.0 # EXP reward for disarming a trap + var all_players = get_tree().get_nodes_in_group("player") + var valid_players = [] + for player in all_players: + if is_instance_valid(player) and player.character_stats: + valid_players.append(player) + + if valid_players.size() > 0: + # Split EXP evenly among all players + var exp_per_player = trap_exp_reward / valid_players.size() + for player in valid_players: + player.character_stats.add_xp(exp_per_player) + LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_DUNGEON) + + # Sync EXP to client if this player belongs to a client + var player_peer_id = player.get_multiplayer_authority() + if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"): + var coins = player.character_stats.coin if "coin" in player.character_stats else 0 + var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0 + player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp) + + # Show floating EXP text at trap position and sync to all clients + if is_instance_valid(trap) and "global_position" in trap: + # Show locally first + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_show_exp_number_at_position"): + game_world._show_exp_number_at_position(exp_per_player, trap.global_position) + # Sync to all clients via game_world + if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): + game_world._sync_exp_text_at_position.rpc(exp_per_player, trap.global_position) @rpc("authority", "reliable") func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool): @@ -5653,7 +5887,7 @@ func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0): # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - if retry_count < 15: # Reduced from 30 + if retry_count < 15: # Reduced from 30 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_broken_objects_to_client(client_peer_id, retry_count + 1) @@ -5727,7 +5961,7 @@ func _sync_existing_torches_to_client(client_peer_id: int, retry_count: int = 0) # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - if retry_count < 15: # Reduced from 30 + if retry_count < 15: # Reduced from 30 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_torches_to_client(client_peer_id, retry_count + 1) @@ -5743,7 +5977,7 @@ func _sync_existing_torches_to_client(client_peer_id: int, retry_count: int = 0) var peers = multiplayer.get_peers() if client_peer_id not in peers: LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before torch sync RPC, retrying...", LogManager.CATEGORY_NETWORK) - if retry_count < 15: # Reduced from 30 + if retry_count < 15: # Reduced from 30 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_torches_to_client(client_peer_id, retry_count + 1) @@ -5796,7 +6030,7 @@ func _sync_existing_door_states_to_client(client_peer_id: int, retry_count: int # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - if retry_count < 20: # Reduced from 60 + if retry_count < 20: # Reduced from 60 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_door_states_to_client(client_peer_id, retry_count + 1) @@ -5811,7 +6045,7 @@ func _sync_existing_door_states_to_client(client_peer_id: int, retry_count: int var peers = multiplayer.get_peers() if client_peer_id not in peers: LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before door sync RPC, retrying...", LogManager.CATEGORY_NETWORK) - if retry_count < 20: # Reduced from 60 + if retry_count < 20: # Reduced from 60 get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): _sync_existing_door_states_to_client(client_peer_id, retry_count + 1) @@ -5965,7 +6199,7 @@ func _sync_activated_switches_to_client(client_peer_id: int, retry_count: int = # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): - var max_retries = 20 # Reduced from 30/60 + var max_retries = 20 # Reduced from 30/60 if retry_count < max_retries: get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -5980,7 +6214,7 @@ func _sync_activated_switches_to_client(client_peer_id: int, retry_count: int = var peers = multiplayer.get_peers() if client_peer_id not in peers: LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before switch sync RPC, retrying...", LogManager.CATEGORY_NETWORK) - var max_retries = 20 # Reduced from 30/60 + var max_retries = 20 # Reduced from 30/60 if retry_count < max_retries: get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -6028,9 +6262,9 @@ func _sync_interactable_object_positions_to_client(client_peer_id: int, retry_co # Check if peer is recognized before sending RPC if not _check_peer_recognized(client_peer_id): # Reduced retries - don't retry so many times for host - var max_retries = 20 # Reduced from 30/60 + var max_retries = 20 # Reduced from 30/60 if retry_count < max_retries: - if retry_count % 10 == 0: # Log every 10th retry to reduce spam + if retry_count % 10 == 0: # Log every 10th retry to reduce spam LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not ready for interactable object sync, retrying (" + str(retry_count + 1) + "/" + str(max_retries) + ")...", LogManager.CATEGORY_NETWORK) get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -6046,7 +6280,7 @@ func _sync_interactable_object_positions_to_client(client_peer_id: int, retry_co var peers = multiplayer.get_peers() if client_peer_id not in peers: LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before interactable object sync RPC, retrying...", LogManager.CATEGORY_NETWORK) - var max_retries = 20 # Reduced from 30/60 + var max_retries = 20 # Reduced from 30/60 if retry_count < max_retries: get_tree().create_timer(0.2).timeout.connect(func(): if is_inside_tree() and multiplayer.is_server(): @@ -6240,7 +6474,7 @@ func _apply_pending_door_state(door: Node): var test_state = pending_door_states[key] if test_state.has("position"): var state_pos = test_state.position - if door_pos.distance_to(state_pos) < 1.0: # Same position + if door_pos.distance_to(state_pos) < 1.0: # Same position state = test_state state_key = key print("GameWorld: Matched door state by position for ", door.name, " (matched key: ", key, ")") @@ -6460,7 +6694,7 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): for player in players: if player.get("peer_id") != null: sorted_players.append(player) - sorted_players.sort_custom(func(a, b): + sorted_players.sort_custom(func(a, b): if a.peer_id != b.peer_id: return a.peer_id < b.peer_id var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0 @@ -6480,27 +6714,64 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): if player.peer_id < new_peer_id: used_spawn_index += 1 - # Assign spawn points to new joiner's players (starting from the next available spawn point) - var spawn_index = used_spawn_index - for player in new_joiner_players: - if spawn_index < player_manager.spawn_points.size(): - var new_pos = player_manager.spawn_points[spawn_index] - player.global_position = new_pos - LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) - spawn_index += 1 - else: - # Fallback: place in center of start room - var room_center_x = (start_room.x + start_room.w / 2.0) * 16 - var room_center_y = (start_room.y + start_room.h / 2.0) * 16 - var fallback_pos = Vector2(room_center_x, room_center_y) - player.global_position = fallback_pos - LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) - # Host never receives _sync_player_position_by_name; ensure joiner is visible on host - player.visible = true + # Check if entrance exists (level > 1) - joiners should use entrance if available + var has_entrance = not dungeon_data.is_empty() and dungeon_data.has("entrance") and not dungeon_data.entrance.is_empty() + + if has_entrance and current_level > 1: + # Position new joiner in entrance and start walk-out sequence (same as level transition) + # CRITICAL: Disable falling cutscene for joiners in entrance (they should walk out, not fall) + for player in new_joiner_players: + if is_instance_valid(player): + player.spawn_landing = false + player.position_z = 0.0 + player.velocity_z = 0.0 + player.is_airborne = false + _position_players_in_entrance(new_joiner_players) + else: + # Normal spawn positioning (level 1 or no entrance) + # Assign spawn points to new joiner's players (starting from the next available spawn point) + var spawn_index = used_spawn_index + for player in new_joiner_players: + if spawn_index < player_manager.spawn_points.size(): + var new_pos = player_manager.spawn_points[spawn_index] + + # CRITICAL: Verify spawn position is safe (on floor, not in wall) + if not _is_safe_spawn_position(new_pos): + # Spawn position is not safe, find a nearby safe position + var safe_pos = _find_nearby_safe_spawn_position(new_pos, 128.0) + LogManager.log("GameWorld: WARNING - Spawn position " + str(new_pos) + " for new joiner " + player.name + " was unsafe, using safe position: " + str(safe_pos), LogManager.CATEGORY_GAMEPLAY) + new_pos = safe_pos + + player.global_position = new_pos + LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) + spawn_index += 1 + else: + # Fallback: place in center of start room + var room_center_x = (start_room.x + start_room.w / 2.0) * 16 + var room_center_y = (start_room.y + start_room.h / 2.0) * 16 + var fallback_pos = Vector2(room_center_x, room_center_y) + + # CRITICAL: Verify fallback position is safe + if not _is_safe_spawn_position(fallback_pos): + fallback_pos = _find_nearby_safe_spawn_position(fallback_pos, 128.0) + LogManager.log("GameWorld: WARNING - Fallback spawn position for new joiner " + player.name + " was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) + + player.global_position = fallback_pos + LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) + + # Host never receives _sync_player_position_by_name; ensure joiner is visible on host + player.visible = true + + # CRITICAL: Remove black fade overlay for new joiner (in case it was left from previous level) + # This prevents joiners from seeing a black screen + # Remove on server (for host's view) and sync to joiner's client + _remove_black_fade_overlay() + if multiplayer.has_multiplayer_peer(): + _sync_remove_black_fade.rpc_id(new_peer_id) # Send ALL current player positions to the new joiner (so they see everyone correctly) # Wait longer to ensure the client has fully loaded the game scene and all player nodes are spawned - await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning + await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning for player in sorted_players: if player.is_inside_tree() and is_instance_valid(player): @@ -6514,7 +6785,7 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): for slot_name in player.character_stats.equipment.keys(): var item = player.character_stats.equipment[slot_name] if item: - equipment_data[slot_name] = item.save() # Serialize item data + equipment_data[slot_name] = item.save() # Serialize item data else: equipment_data[slot_name] = null # Send equipment sync via player's RPC (the player node will route it correctly) @@ -6581,7 +6852,7 @@ func _move_all_players_to_start_room(): for player in players: if player.get("peer_id") != null: sorted_players.append(player) - sorted_players.sort_custom(func(a, b): + sorted_players.sort_custom(func(a, b): if a.peer_id != b.peer_id: return a.peer_id < b.peer_id var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0 @@ -6657,7 +6928,7 @@ func _position_players_in_entrance(players: Array): # Position each player inside the entrance (stacked slightly) var entrance_center = entrance_data.world_pos - var player_spacing = 8.0 # Small spacing between players + var player_spacing = 8.0 # Small spacing between players for i in range(players.size()): var player = players[i] @@ -6665,8 +6936,8 @@ func _position_players_in_entrance(players: Array): continue # Position player inside entrance (slightly offset based on index) - var offset_x = (i % 3) * player_spacing - player_spacing # Spread horizontally - var offset_y = int(float(i) / 3.0) * player_spacing # Stack vertically + var offset_x = (i % 3) * player_spacing - player_spacing # Spread horizontally + var offset_y = int(float(i) / 3.0) * player_spacing # Stack vertically var player_pos = entrance_center + Vector2(offset_x, offset_y) player.global_position = player_pos @@ -6706,7 +6977,7 @@ func _start_entrance_walk_out(): var walk_direction := Vector2.DOWN if entrance_data.has("dir"): match entrance_data.dir: - "UP": walk_direction = Vector2.DOWN # entrance on top wall -> walk down into room + "UP": walk_direction = Vector2.DOWN # entrance on top wall -> walk down into room "DOWN": walk_direction = Vector2.UP "LEFT": walk_direction = Vector2.RIGHT "RIGHT": walk_direction = Vector2.LEFT @@ -6748,7 +7019,7 @@ func _start_entrance_walk_out(): if direction.length() < 0.1: direction = walk_direction - player.velocity = direction * 120.0 # Walk speed for cut-scene + player.velocity = direction * 120.0 # Walk speed for cut-scene # Store target position for checking completion player.set_meta("entrance_walk_target", target_pos) @@ -6780,7 +7051,7 @@ func _check_entrance_walk_out_complete(): if player.has_meta("entrance_walk_target"): var target_pos = player.get_meta("entrance_walk_target") var distance_to_target = player.global_position.distance_to(target_pos) - if distance_to_target < 16.0: # Close enough to target (inside room) + if distance_to_target < 16.0: # Close enough to target (inside room) has_reached_target = true player.remove_meta("entrance_walk_target") @@ -6867,7 +7138,7 @@ func _close_entrance(): # Check if gate already exists (gate is under Entities) var existing_gate = entities_node.get_node_or_null("EntranceGateDoor") if existing_gate: - return # Already closed + return # Already closed # Load door scene var door_scene = load("res://scenes/door.tscn") @@ -6884,7 +7155,7 @@ func _close_entrance(): gate_door.name = "EntranceGateDoor" gate_door.type = "GateDoor" gate_door.direction = gate_dir - gate_door.is_closed = false # Start OPEN so we can animate close + play SfxCloseGateDoor + gate_door.is_closed = false # Start OPEN so we can animate close + play SfxCloseGateDoor gate_door.requires_enemies = false gate_door.requires_switch = false @@ -6948,7 +7219,7 @@ func _sync_entrance_gate_door(gate_pos: Vector2, gate_dir: String): var existing_gate = entities_node.get_node_or_null("EntranceGateDoor") if existing_gate: - return # Already exists + return # Already exists var door_scene = load("res://scenes/door.tscn") if not door_scene: @@ -6958,7 +7229,7 @@ func _sync_entrance_gate_door(gate_pos: Vector2, gate_dir: String): gate_door.name = "EntranceGateDoor" gate_door.type = "GateDoor" gate_door.direction = gate_dir - gate_door.is_closed = false # Start OPEN; host will sync close + sound + gate_door.is_closed = false # Start OPEN; host will sync close + sound gate_door.requires_enemies = false gate_door.requires_switch = false gate_door.global_position = gate_pos @@ -6984,7 +7255,7 @@ func _get_free_floor_tiles_in_room(room: Dictionary) -> Array: for y in range(room.y + 2, room.y + room.h - 2): # Check if tile is floor (grid value 1) if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y: - if dungeon_data.grid[x][y] == 1: # Floor tile + if dungeon_data.grid[x][y] == 1: # Floor tile free_tiles.append({"x": x, "y": y}) return free_tiles @@ -7111,6 +7382,17 @@ func _on_player_reached_stairs(player: Node): # Mark as triggered to prevent re-triggering level_complete_triggered = true + # CRITICAL: Disable stairs so no other player can activate it + # Use set_deferred() because we're in a signal callback (body_entered) + var stairs_area = get_node_or_null("StairsArea") + if stairs_area: + stairs_area.set_deferred("monitoring", false) + stairs_area.set_deferred("monitorable", false) + LogManager.log("GameWorld: Disabled stairs Area2D to prevent other players from activating", LogManager.CATEGORY_DUNGEON) + # Sync stairs disable to all clients + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_disable_stairs", []) + # Disable controls and collision for the player who reached stairs var player_peer_id = player.get_multiplayer_authority() if player.has_method("get_multiplayer_authority") else 0 player.controls_disabled = true @@ -7149,6 +7431,38 @@ func _on_player_reached_stairs(player: Node): # Stop background music when level completes _stop_bg_music() + # Grant EXP to all players for completing the level + var level_completion_exp = 15.0 # EXP reward for completing a level + var all_players = get_tree().get_nodes_in_group("player") + var valid_players = [] + for p in all_players: + if is_instance_valid(p) and p.character_stats: + valid_players.append(p) + + if valid_players.size() > 0: + # Split EXP evenly among all players + var exp_per_player = level_completion_exp / valid_players.size() + for p in valid_players: + p.character_stats.add_xp(exp_per_player) + LogManager.log("Level complete: granted " + str(exp_per_player) + " EXP to " + str(p.name) + " (shared from " + str(level_completion_exp) + " total)", LogManager.CATEGORY_DUNGEON) + + # Sync EXP to client if this player belongs to a client + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var p_peer_id = p.get_multiplayer_authority() + if p_peer_id != 0 and p_peer_id != multiplayer.get_unique_id() and p.has_method("_sync_stats_update"): + var coins = p.character_stats.coin if "coin" in p.character_stats else 0 + var xp = p.character_stats.xp if "xp" in p.character_stats else 0.0 + p._sync_stats_update.rpc_id(p_peer_id, p.character_stats.kills, coins, xp) + + # Show floating EXP text at player position and sync to all clients + if multiplayer.is_server() and is_instance_valid(p): + # Show locally first + _show_exp_number_at_player(exp_per_player, p) + # Sync to all clients via RPC + if multiplayer.has_multiplayer_peer(): + var p_peer_id = p.get_multiplayer_authority() + _sync_exp_text_at_player.rpc(exp_per_player, p_peer_id) + # Show level complete UI (server and clients) with per-player stats _show_level_complete_ui(level_time) # Sync to all clients (each client will show their own local player's stats) @@ -7238,7 +7552,7 @@ func _on_player_reached_stairs(player: Node): # Use blob sync for all connected peers var peers = multiplayer.get_peers() for peer_id in peers: - if peer_id != 1: # Don't sync to self (server is peer 1) + if peer_id != 1: # Don't sync to self (server is peer 1) print("GameWorld: HOST - Syncing new level (from stairs) to peer ", peer_id, " using blob sync") _sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, start_room) @@ -7323,7 +7637,7 @@ func _fade_in_all_players(): # Start background music after all players fade in (when new level is ready) # Wait for all fade tweens to complete if fade_tweens.size() > 0: - await fade_tweens[0].finished # Wait for first player's fade (they should all finish around the same time) + await fade_tweens[0].finished # Wait for first player's fade (they should all finish around the same time) _start_bg_music() func _fade_in_player(player: Node) -> Tween: @@ -7372,20 +7686,20 @@ func _show_black_fade_overlay(): # Create CanvasLayer with z_index 999 (below level complete UI which is 1000) var fade_layer = CanvasLayer.new() fade_layer.name = "BlackFadeOverlay" - fade_layer.layer = 999 # Below level complete UI (1000) but above gameplay + fade_layer.layer = 999 # Below level complete UI (1000) but above gameplay add_child(fade_layer) # Create ColorRect that fills the screen var fade_rect = ColorRect.new() fade_rect.name = "FadeRect" - fade_rect.color = Color(0, 0, 0, 1) # Black, fully opaque - fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT) # Fill entire screen + fade_rect.color = Color(0, 0, 0, 1) # Black, fully opaque + fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT) # Fill entire screen fade_layer.add_child(fade_rect) # Fade in from transparent to black - fade_rect.modulate.a = 0.0 # Start transparent + fade_rect.modulate.a = 0.0 # Start transparent var fade_tween = create_tween() - fade_tween.tween_property(fade_rect, "modulate:a", 1.0, 0.5) # Fade in over 0.5 seconds + fade_tween.tween_property(fade_rect, "modulate:a", 1.0, 0.5) # Fade in over 0.5 seconds LogManager.log("GameWorld: Created black fade overlay for player who reached exit", LogManager.CATEGORY_DUNGEON) func _remove_black_fade_overlay(): @@ -7396,7 +7710,7 @@ func _remove_black_fade_overlay(): var fade_rect = existing_fade.get_node_or_null("FadeRect") if fade_rect: var fade_tween = create_tween() - fade_tween.tween_property(fade_rect, "modulate:a", 0.0, 0.2) # Fade out over 0.2 seconds + fade_tween.tween_property(fade_rect, "modulate:a", 0.0, 0.2) # Fade out over 0.2 seconds await fade_tween.finished existing_fade.queue_free() LogManager.log("GameWorld: Removed black fade overlay", LogManager.CATEGORY_DUNGEON) @@ -7468,7 +7782,7 @@ func _play_level_complete_sound(): # Create AudioStreamPlayer (not 2D, so it plays globally) var audio_player = AudioStreamPlayer.new() audio_player.stream = sound_stream - audio_player.volume_db = linear_to_db(0.6) # 60% volume + audio_player.volume_db = linear_to_db(0.6) # 60% volume add_child(audio_player) audio_player.play() # Clean up after sound finishes @@ -7598,6 +7912,78 @@ func _load_floating_text_layer() -> void: layer.follow_viewport_enabled = true add_child(layer) +@rpc("any_peer", "call_local", "reliable") +func _sync_exp_text_at_position(amount: float, exp_pos: Vector2): + # Sync EXP text display to all clients at a specific position + _show_exp_number_at_position(amount, exp_pos) + +@rpc("any_peer", "call_local", "reliable") +func _sync_exp_text_at_player(amount: float, player_peer_id: int): + # Sync EXP text display to all clients at a player's position + var player = null + var players = get_tree().get_nodes_in_group("player") + for p in players: + if p.get_multiplayer_authority() == player_peer_id: + player = p + break + + if player and is_instance_valid(player): + _show_exp_number_at_player(amount, player) + +func _show_exp_number_at_position(amount: float, exp_pos: Vector2): + # Show EXP number (green, using dmg_numbers.png font) at a specific position + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene: + return + + var exp_label = damage_number_scene.instantiate() + if not exp_label: + return + + # Set text and color for EXP (green) + exp_label.label = "+" + str(int(amount)) + " EXP" + exp_label.color = Color(0.4, 1.0, 0.4) # Bright green + exp_label.z_index = 5 + + # Direction is straight up + exp_label.direction = Vector2(0, -1) + + # Position at specified location + var entities_node = get_node_or_null("Entities") + if entities_node: + entities_node.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) + +func _show_exp_number_at_player(amount: float, player: Node): + # Show EXP number (green, using dmg_numbers.png font) at player position + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene or not is_instance_valid(player): + return + + var exp_label = damage_number_scene.instantiate() + if not exp_label: + return + + # Set text and color for EXP (green) + exp_label.label = "+" + str(int(amount)) + " EXP" + exp_label.color = Color(0.4, 1.0, 0.4) # Bright green + exp_label.z_index = 5 + + # Direction is straight up + exp_label.direction = Vector2(0, -1) + + # Position at player location + var entities_node = get_node_or_null("Entities") + if entities_node: + entities_node.add_child(exp_label) + exp_label.global_position = player.global_position + Vector2(0, -20) # Above player + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = player.global_position + Vector2(0, -20) + func world_to_screen(world_pos: Vector2) -> Vector2: var vp = get_viewport() if not vp: @@ -7726,7 +8112,7 @@ func _sync_object_break(obj_name: String): # Skip processing if tab was just inactive (may be stale messages from buffer overflow) if not multiplayer.is_server() and not was_tab_visible: - return # Tab was inactive, skip old/stale RPCs to prevent sound spam + return # Tab was inactive, skip old/stale RPCs to prevent sound spam var entities_node = get_node_or_null("Entities") if not entities_node: @@ -7738,7 +8124,7 @@ func _sync_object_break(obj_name: String): # If not found and name looks like "InteractableObject_X", try extracting index and searching by meta if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() # Search all children for object with matching object_index meta @@ -7771,7 +8157,7 @@ func _sync_object_break(obj_name: String): if obj.has_meta("object_index"): obj_index = obj.get_meta("object_index") elif obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): obj_index = index_str.to_int() @@ -7844,6 +8230,46 @@ func _request_register_player_died(player_peer_id: int): _run_respawn_all_check() return +func _unregister_player_died(player: Node): + # Remove player from dead_players dictionary when they're revived + # Authority runs this; if client, we RPC server so server has full picture. + if not is_inside_tree(): + return + var peer_id: int = 0 + if player and player.has_method("get_multiplayer_authority"): + peer_id = player.get_multiplayer_authority() + var n = str(player.name) if player else "" + if n.is_empty(): + return + if multiplayer.is_server() and multiplayer.has_multiplayer_peer(): + if dead_players.has(n): + dead_players.erase(n) + LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON) + elif multiplayer.has_multiplayer_peer(): + _request_unregister_player_died.rpc_id(1, peer_id) + else: + # Single-player: we are "server" + if dead_players.has(n): + dead_players.erase(n) + LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON) + +@rpc("any_peer", "reliable") +func _request_unregister_player_died(player_peer_id: int): + if not multiplayer.is_server(): + return + var pm = get_node_or_null("PlayerManager") + if not pm or not pm.has_method("get_all_players"): + return + for p in pm.get_all_players(): + if not is_instance_valid(p): + continue + if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id: + var n = str(p.name) + if not n.is_empty() and dead_players.has(n): + dead_players.erase(n) + LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON) + return + func _are_all_players_dead_server() -> bool: var pm = get_node_or_null("PlayerManager") if not pm or not pm.has_method("get_all_players"): @@ -7851,13 +8277,49 @@ func _are_all_players_dead_server() -> bool: var all_p = pm.get_all_players() if all_p.is_empty(): return false + + # Get list of connected peer IDs (exclude disconnected players) + var connected_peer_ids: Array[int] = [] + if multiplayer.has_multiplayer_peer(): + # Server's own peer ID (1) is always connected + connected_peer_ids.append(1) + # Add all other connected peers + for peer_id in multiplayer.get_peers(): + connected_peer_ids.append(peer_id) + else: + # Single-player: only check local players + connected_peer_ids.append(1) + + # Only check players that are connected (not disconnected) + var has_connected_players = false for p in all_p: if not is_instance_valid(p): - return false + continue + + # Get player's peer ID + var player_peer_id = 0 + if p.has_method("get_multiplayer_authority"): + player_peer_id = p.get_multiplayer_authority() + elif "peer_id" in p: + player_peer_id = p.peer_id + + # Skip disconnected players (not in connected_peer_ids) + if player_peer_id > 0 and player_peer_id not in connected_peer_ids: + continue + + # This is a connected player - check if they're dead + has_connected_players = true var in_dead = dead_players.has(str(p.name)) var node_dead = "is_dead" in p and p.is_dead if not in_dead and not node_dead: + # Found a connected player who is not dead return false + + # If no connected players found, return false (shouldn't happen, but safety check) + if not has_connected_players: + return false + + # All connected players are dead return true func _run_respawn_all_check(): @@ -7870,8 +8332,16 @@ func _run_respawn_all_check(): respawn_all_check_running = false if not is_inside_tree(): return + + # CRITICAL: Double-check that all players are still dead before showing game over + # This prevents showing game over if level transition happened during the wait + if not _are_all_players_dead_server(): + # Players are no longer dead (likely level transition happened), abort + dead_players.clear() + return + if game_over_triggered: - pass # Already shown (e.g. by earlier logic) + pass # Already shown (e.g. by earlier logic) else: game_over_triggered = true _show_game_over_local() @@ -7880,6 +8350,18 @@ func _run_respawn_all_check(): await get_tree().create_timer(0.5).timeout if not is_inside_tree(): return + + # CRITICAL: Check again after the wait - level transition might have happened + if not _are_all_players_dead_server(): + # Players are no longer dead (level transition happened), abort and reset + game_over_triggered = false + dead_players.clear() + # Hide game over UI if it was shown + var game_over_ui = get_node_or_null("GameOverUI") + if game_over_ui: + game_over_ui.visible = false + return + dead_players.clear() respawn_all_ready.emit() if multiplayer.has_multiplayer_peer() and is_inside_tree(): @@ -7888,13 +8370,13 @@ func _run_respawn_all_check(): @rpc("any_peer", "reliable") func _sync_respawn_all(): if multiplayer.is_server(): - return # Server already ran respawn_all_ready locally + return # Server already ran respawn_all_ready locally respawn_all_ready.emit() func _show_game_over(): # Show game over UI when all players die (legacy / fallback; server uses _run_respawn_all_check) if game_over_triggered: - return # Already shown + return # Already shown game_over_triggered = true @@ -7912,7 +8394,7 @@ func _show_game_over_local(): # Create game over UI programmatically (similar to level complete) var canvas_layer = CanvasLayer.new() canvas_layer.name = "GameOverUI" - canvas_layer.layer = 1001 # Above level complete UI (1000) + canvas_layer.layer = 1001 # Above level complete UI (1000) add_child(canvas_layer) # Load standard font @@ -7929,7 +8411,7 @@ func _show_game_over_local(): # Center the VBoxContainer var screen_size = get_viewport().get_visible_rect().size vbox.set_anchors_preset(Control.PRESET_CENTER) - vbox.offset_left = -screen_size.x / 2 + vbox.offset_left = - screen_size.x / 2 vbox.offset_right = screen_size.x / 2 vbox.offset_top = -100 vbox.offset_bottom = screen_size.y / 2 - 100 @@ -7971,7 +8453,7 @@ func _sync_show_game_over(): # Sync game over screen to other peer(s). Either host or joiner can trigger when both dead. # Sender already showed locally in _show_game_over; we only run when receiving from the other peer. if game_over_triggered: - return # Already shown (e.g. we triggered, or we already processed this RPC) + return # Already shown (e.g. we triggered, or we already processed this RPC) game_over_triggered = true _show_game_over_local() @@ -7984,7 +8466,7 @@ func _play_game_over_sound(): # Create AudioStreamPlayer (not 2D, so it plays globally) var audio_player = AudioStreamPlayer.new() audio_player.stream = sound_stream - audio_player.volume_db = linear_to_db(0.6) # 60% volume + audio_player.volume_db = linear_to_db(0.6) # 60% volume add_child(audio_player) audio_player.play() # Clean up after sound finishes @@ -8014,7 +8496,7 @@ func _fade_in_game_graphics(): # Fade in CanvasModulate quickly var fade_tween = create_tween() - fade_tween.tween_property(canvas_modulate, "color", original_color, 0.2) # Quick fade in (0.2s) + fade_tween.tween_property(canvas_modulate, "color", original_color, 0.2) # Quick fade in (0.2s) # Also fade in Environment (tilemaps) if environment: @@ -8024,7 +8506,7 @@ func _fade_in_game_graphics(): func _hide_game_over(): # Hide GAME OVER screen when player respawns if not game_over_triggered: - return # Not shown, nothing to hide + return # Not shown, nothing to hide game_over_triggered = false @@ -8051,11 +8533,125 @@ func _sync_arrow_collected(arrow_name: String): if arrow and is_instance_valid(arrow): arrow.call_deferred("queue_free") +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_hit(arrow_name: String, target_name: String): + # Route arrow hit sync through game_world to avoid node path issues + # Find arrow by name (may be in Entities or as child of target) + if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty(): + return + + var arrow = _find_arrow_by_name(arrow_name) + if not arrow or not is_instance_valid(arrow): + LogManager.log("GameWorld: Arrow not found for hit sync: " + arrow_name, LogManager.CATEGORY_DUNGEON) + return + + # Find target by name in Entities node + var target = null + var entities_node = get_node_or_null("Entities") + if entities_node: + target = entities_node.get_node_or_null(target_name) + # Also check if target is a child of Entities (arrow might be stuck to it) + if not target: + for child in entities_node.get_children(): + if child.name == target_name: + target = child + break + + if not target: + LogManager.log("GameWorld: Arrow hit target not found: " + target_name, LogManager.CATEGORY_DUNGEON) + return + + # Call the arrow's internal sync method + if arrow.has_method("_process_arrow_hit_sync"): + arrow._process_arrow_hit_sync(target) + else: + # Fallback: directly stick to target + if arrow.has_method("_stick_to_target") and target not in arrow.hit_targets: + arrow.hit_targets[target] = true + if arrow.has_method("play_impact"): + arrow.play_impact() + arrow._stick_to_target(target) + +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_miss(arrow_name: String, target_name: String): + # Route arrow miss sync through game_world to avoid node path issues + if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty(): + return + + var arrow = _find_arrow_by_name(arrow_name) + if not arrow or not is_instance_valid(arrow): + return + + # Find target by name in Entities node + var target = null + var entities_node = get_node_or_null("Entities") + if entities_node: + target = entities_node.get_node_or_null(target_name) + + if target and target not in arrow.hit_targets: + arrow.hit_targets[target] = true + LogManager.log("GameWorld: Arrow synced as MISS - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON) + +@rpc("any_peer", "call_local", "reliable") +func _sync_arrow_dodge(arrow_name: String, target_name: String): + # Route arrow dodge sync through game_world to avoid node path issues + if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty(): + return + + var arrow = _find_arrow_by_name(arrow_name) + if not arrow or not is_instance_valid(arrow): + return + + # Find target by name in Entities node + var target = null + var entities_node = get_node_or_null("Entities") + if entities_node: + target = entities_node.get_node_or_null(target_name) + + if target and target not in arrow.hit_targets: + arrow.hit_targets[target] = true + LogManager.log("GameWorld: Arrow synced as DODGE - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON) + +func _find_arrow_by_name(arrow_name: String) -> Node: + # Find arrow by name - check Entities first, then check children of entities (arrows stuck to enemies) + var entities_node = get_node_or_null("Entities") + if not entities_node: + return null + + # First check direct children of Entities + var arrow = entities_node.get_node_or_null(arrow_name) + if arrow and is_instance_valid(arrow): + return arrow + + # Recursively search children of Entities' children (arrows stuck to enemies/players) + # Arrows can be nested deeper when stuck to targets + for child in entities_node.get_children(): + var found = _find_node_recursive(child, arrow_name) + if found: + return found + + return null + +func _find_node_recursive(node: Node, target_name: String) -> Node: + # Recursively search for a node by name + if not is_instance_valid(node): + return null + + if node.name == target_name: + return node + + for child in node.get_children(): + var found = _find_node_recursive(child, target_name) + if found: + return found + + return null + func _create_level_complete_ui_programmatically() -> Node: # Create level complete UI programmatically var canvas_layer = CanvasLayer.new() canvas_layer.name = "LevelCompleteUI" - canvas_layer.layer = 1000 # Very high z_index so it appears above black fade + canvas_layer.layer = 1000 # Very high z_index so it appears above black fade add_child(canvas_layer) # Load standard font (as FontFile) @@ -8170,11 +8766,11 @@ func _create_level_text_ui_programmatically() -> Node: # Center horizontally and position higher up var screen_size = get_viewport().get_visible_rect().size - vbox.offset_left = -screen_size.x / 2 + vbox.offset_left = - screen_size.x / 2 vbox.offset_right = screen_size.x / 2 - vbox.offset_top = -250 # Position higher up from center + vbox.offset_top = -250 # Position higher up from center vbox.offset_bottom = screen_size.y / 2 - 250 - vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill width for proper centering + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill width for proper centering canvas_layer.add_child(vbox) @@ -8184,7 +8780,7 @@ func _create_level_text_ui_programmatically() -> Node: level_label.text = "LEVEL 1" level_label.add_theme_font_size_override("font_size", 64) level_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - level_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to center properly + level_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to center properly # Load standard_font.png as bitmap font var standard_font_resource = null @@ -8846,7 +9442,7 @@ func _place_key_in_room(room: Dictionary): if valid_positions.size() > 0: # Use deterministic seed for key placement (ensures same position on host and clients) var rng = RandomNumberGenerator.new() - var key_seed = dungeon_seed + (room.x * 1000) + (room.y * 100) + 5000 # Offset to avoid collisions with other objects + var key_seed = dungeon_seed + (room.x * 1000) + (room.y * 100) + 5000 # Offset to avoid collisions with other objects rng.seed = key_seed var key_pos = valid_positions[rng.randi() % valid_positions.size()] diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index e60deef..e7c3cc2 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -233,12 +233,12 @@ func _handle_air_collision(): _break_into_pieces() - # Damage and knockback player using RPC (pots deal less damage than boxes) + # Damage and knockback player using RPC (any thrown object deals max 1 damage to other players) # Player's take_damage() already handles defense calculation # Pass the thrower's position for accurate direction if collider.has_method("rpc_take_damage"): var attacker_pos = thrown_by_player.global_position if thrown_by_player and is_instance_valid(thrown_by_player) else global_position - var base_damage = 7.0 if object_type == "Pot" else 10.0 + var base_damage = 1.0 # Max 1 damage to other players for any thrown object var player_peer_id = collider.get_multiplayer_authority() if player_peer_id != 0: # If target peer is the same as server (us), call directly @@ -338,7 +338,7 @@ func _break_into_pieces(silent: bool = false): # Spawn item loot when breaking (30% chance) if is_multiplayer_authority(): var drop_chance = randf() - if drop_chance < 0.3: # 30% chance to drop item + if drop_chance < 0.3: # 30% chance to drop item var item = ItemDatabase.get_random_container_item() if item: var entities_node = get_parent() @@ -470,7 +470,7 @@ func on_grabbed(by_player): else: # Server or single player - open directly _open_chest(by_player) - return # CRITICAL: Return early to prevent normal grab behavior + return # CRITICAL: Return early to prevent normal grab behavior is_being_held = true held_by_player = by_player @@ -554,7 +554,7 @@ func _convert_to_bomb_projectile(by_player, force: Vector2): var bomb = attack_bomb_scene.instantiate() bomb.name = "ThrownBomb_" + name get_parent().add_child(bomb) - bomb.global_position = current_pos # Use current position, not target + bomb.global_position = current_pos # Use current position, not target # Set multiplayer authority if multiplayer.has_multiplayer_peer(): @@ -562,15 +562,24 @@ func _convert_to_bomb_projectile(by_player, force: Vector2): # Setup bomb with throw physics (pass force as throw_velocity) # The bomb will use throw_velocity for movement - bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity + bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity # Make sure bomb sprite is visible if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true - # Sync bomb throw to other clients (pass our name so they can free the lifted bomb) - if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): - by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) + # Sync bomb throw to other clients + if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority(): + if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): + # Player threw: sync via player RPC (pass our name so they can free the lifted bomb) + by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) + elif by_player.is_in_group("enemy"): + # Enemy threw: sync via game_world so joiners see the bomb + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + var enemy_index = by_player.get_meta("enemy_index") if by_player.has_meta("enemy_index") else -1 + var bomb_name = "EnemyBomb_" + by_player.name + "_" + str(Time.get_ticks_msec()) + game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, by_player.name, enemy_index, current_pos, force]) # Remove the interactable object queue_free() @@ -602,7 +611,7 @@ func setup_pot(): can_be_pushed = true is_destroyable = true is_liftable = true - weight = 0.8 # Pots are very light and easy to throw far! + weight = 0.8 # Pots are very light and easy to throw far! var pot_frames = [1, 2, 3, 20, 21, 22, 58] if sprite: @@ -661,7 +670,7 @@ func setup_box(): can_be_pushed = true is_destroyable = true is_liftable = true - weight = 1.5 # Boxes are heavier than pots + weight = 1.5 # Boxes are heavier than pots var box_frames = [7, 26] if sprite: @@ -757,9 +766,9 @@ func setup_bomb(): object_type = "Bomb" is_grabbable = true can_be_pushed = false - is_destroyable = false # Bombs don't break, they explode + is_destroyable = false # Bombs don't break, they explode is_liftable = true - weight = 0.5 # Light weight for easy throwing + weight = 0.5 # Light weight for easy throwing # Set bomb sprite (frame 199 from items_n_shit.png) if sprite: @@ -853,7 +862,7 @@ func _open_chest(by_player: Node = null): if chest_item.item_type == Item.ItemType.Restoration: item_color = Color.GREEN elif chest_item.item_type == Item.ItemType.Equippable: - item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) + item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) else: item_color = Color.WHITE @@ -955,7 +964,7 @@ func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0, i if chest_item.item_type == Item.ItemType.Restoration: item_color = Color.GREEN elif chest_item.item_type == Item.ItemType.Equippable: - item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) + item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) if items_texture: _show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index f39c83f..4aaa824 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -207,10 +207,18 @@ func _find_local_player(): if local_players.size() > 0: local_player = local_players[0] if local_player and local_player.character_stats: - # Connect to character_changed signal + # Connect to character_changed signal (for inventory/equipment changes) if local_player.character_stats.character_changed.is_connected(_on_character_changed): local_player.character_stats.character_changed.disconnect(_on_character_changed) local_player.character_stats.character_changed.connect(_on_character_changed) + # Connect to mana_changed signal (for mana updates only - don't rebuild UI) + if local_player.character_stats.mana_changed.is_connected(_on_mana_changed): + local_player.character_stats.mana_changed.disconnect(_on_mana_changed) + local_player.character_stats.mana_changed.connect(_on_mana_changed) + # Connect to health_changed signal (for HP updates only - don't rebuild UI) + if local_player.character_stats.health_changed.is_connected(_on_health_changed): + local_player.character_stats.health_changed.disconnect(_on_health_changed) + local_player.character_stats.health_changed.connect(_on_health_changed) # Initial update _update_ui() _update_stats() @@ -1396,6 +1404,20 @@ func _on_inventory_item_gui_input(event: InputEvent, item: Item): # Use the same logic as E key to drop _handle_e_key() +func _on_mana_changed(new_mana: float, max_mana: float): + # Update only MP bar and label when mana changes (don't rebuild entire UI) + if mp_progress_bar and mp_value_label: + mp_progress_bar.max_value = max(1.0, max_mana) + mp_progress_bar.value = new_mana + mp_value_label.text = str(int(new_mana)) + "/" + str(int(max_mana)) + +func _on_health_changed(new_hp: float, max_hp: float): + # Update only HP bar and label when health changes (don't rebuild entire UI) + if hp_progress_bar and hp_value_label: + hp_progress_bar.max_value = max(1.0, max_hp) + hp_progress_bar.value = new_hp + hp_value_label.text = str(int(new_hp)) + "/" + str(int(max_hp)) + func _on_character_changed(_char: CharacterStats): # Always update stats when character changes (even if inventory is closed) # Equipment changes affect max HP/MP which should be reflected everywhere diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index 3696ea7..15d9259 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -7,11 +7,11 @@ extends RefCounted # Item rarity tiers for random generation enum ItemRarity { - COMMON, # Basic items (food, low-tier equipment) - UNCOMMON, # Mid-tier equipment - RARE, # High-tier equipment - EPIC, # Legendary equipment - CONSUMABLE # Potions and consumables + COMMON, # Basic items (food, low-tier equipment) + UNCOMMON, # Mid-tier equipment + RARE, # High-tier equipment + EPIC, # Legendary equipment + CONSUMABLE # Potions and consumables } # Dictionary to store all item definitions by ID @@ -32,7 +32,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 10, # 10,0 + "spriteFrame": 0 * 20 + 10, # 10,0 "modifiers": {"def": 1, "end": 1}, "buy_cost": 50, "sell_worth": 15, @@ -45,7 +45,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 11, # 11,0 + "spriteFrame": 0 * 20 + 11, # 11,0 "modifiers": {"def": 2}, "buy_cost": 60, "sell_worth": 18, @@ -58,7 +58,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 12, # 12,0 + "spriteFrame": 0 * 20 + 12, # 12,0 "modifiers": {"def": 0}, "buy_cost": 10, "sell_worth": 3, @@ -71,13 +71,13 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 13, # 13,0 + "spriteFrame": 0 * 20 + 13, # 13,0 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Tunic Body/LeatherTunic.png", "modifiers": {"def": 3}, "buy_cost": 80, "sell_worth": 24, "rarity": ItemRarity.COMMON, - "weight": 4.0 # Light armour + "weight": 4.0 # Light armour }) _register_item("plate", { @@ -86,13 +86,13 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 14, # 14,0 + "spriteFrame": 0 * 20 + 14, # 14,0 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Armour Body/SteelArmour.png", "modifiers": {"def": 5, "end": 1}, "buy_cost": 150, "sell_worth": 45, "rarity": ItemRarity.UNCOMMON, - "weight": 10.0 # Heavy armour! + "weight": 10.0 # Heavy armour! }) _register_item("full_mail", { @@ -101,7 +101,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 15, # 15,0 + "spriteFrame": 0 * 20 + 15, # 15,0 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Armour Body/IronArmour.png", "modifiers": {"def": 4}, "buy_cost": 120, @@ -115,7 +115,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ARMOUR, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 16, # 16,0 + "spriteFrame": 0 * 20 + 16, # 16,0 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Armour Body/GoldArmour.png", "modifiers": {"def": 6, "end": 2}, "buy_cost": 300, @@ -130,7 +130,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 17, # 17,0 + "spriteFrame": 0 * 20 + 17, # 17,0 "modifiers": {"def": 2}, "buy_cost": 40, "sell_worth": 12, @@ -143,7 +143,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 18, # 18,0 + "spriteFrame": 0 * 20 + 18, # 18,0 "modifiers": {"def": 3}, "buy_cost": 70, "sell_worth": 21, @@ -156,7 +156,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 0 * 20 + 19, # 19,0 + "spriteFrame": 0 * 20 + 19, # 19,0 "modifiers": {"def": 4}, "buy_cost": 100, "sell_worth": 30, @@ -165,17 +165,17 @@ static func _load_all_items(): # HEADGEAR items # MageHatRed (frame 9) variants - var _mhr_o = [Color(255/255.0,39/255.0,44/255.0), Color(182/255.0,0,0), Color(118/255.0,1/255.0,0), Color(72/255.0,0,12/255.0)] - var _mhr_brown = [Color(139/255.0,90/255.0,43/255.0), Color(101/255.0,67/255.0,33/255.0), Color(80/255.0,50/255.0,20/255.0), Color(50/255.0,30/255.0,10/255.0)] - var _mhr_blue = [Color(30/255.0,80/255.0,180/255.0), Color(20/255.0,50/255.0,120/255.0), Color(10/255.0,30/255.0,80/255.0), Color(5/255.0,15/255.0,50/255.0)] - var _mhr_lightblue = [Color(170/255.0,220/255.0,1.0), Color(130/255.0,190/255.0,1.0), Color(90/255.0,150/255.0,220/255.0), Color(50/255.0,100/255.0,180/255.0)] - var _mhr_white = [Color(250/255.0,250/255.0,250/255.0), Color(220/255.0,220/255.0,220/255.0), Color(190/255.0,190/255.0,190/255.0), Color(150/255.0,150/255.0,150/255.0)] - var _shc_o = [Color(0,180/255.0,157/255.0), Color(0,121/255.0,102/255.0), Color(0,79/255.0,67/255.0), Color(0,46/255.0,93/255.0)] - var _shc_red = [Color(180/255.0,40/255.0,40/255.0), Color(130/255.0,0,0), Color(90/255.0,0,0), Color(60/255.0,0,0)] - var _shc_green = [Color(40/255.0,160/255.0,40/255.0), Color(0,120/255.0,0), Color(0,80/255.0,0), Color(0,50/255.0,0)] - var _sb_o = [Color(248/255.0,219/255.0,108/255.0), Color(225/255.0,159/255.0,57/255.0), Color(199/255.0,115/255.0,29/255.0), Color(151/255.0,73/255.0,9/255.0), Color(108/255.0,43/255.0,0), Color(58/255.0,23/255.0,11/255.0), Color(0,76/255.0,218/255.0), Color(9/255.0,35/255.0,105/255.0), Color(0,14/255.0,62/255.0)] - var _sb_iron = [Color(190/255.0,187/255.0,181/255.0), Color(162/255.0,158/255.0,150/255.0), Color(125/255.0,123/255.0,118/255.0), Color(77/255.0,76/255.0,75/255.0), Color(54/255.0,54/255.0,54/255.0), Color(30/255.0,30/255.0,30/255.0), Color(0,76/255.0,218/255.0), Color(9/255.0,35/255.0,105/255.0), Color(0,14/255.0,62/255.0)] - var _sb_steel = [Color(227/255.0,227/255.0,227/255.0), Color(183/255.0,183/255.0,183/255.0), Color(116/255.0,116/255.0,116/255.0), Color(77/255.0,76/255.0,75/255.0), Color(54/255.0,54/255.0,54/255.0), Color(30/255.0,30/255.0,30/255.0), Color(0,76/255.0,218/255.0), Color(9/255.0,35/255.0,105/255.0), Color(0,14/255.0,62/255.0)] + var _mhr_o = [Color(255 / 255.0, 39 / 255.0, 44 / 255.0), Color(182 / 255.0, 0, 0), Color(118 / 255.0, 1 / 255.0, 0), Color(72 / 255.0, 0, 12 / 255.0)] + var _mhr_brown = [Color(139 / 255.0, 90 / 255.0, 43 / 255.0), Color(101 / 255.0, 67 / 255.0, 33 / 255.0), Color(80 / 255.0, 50 / 255.0, 20 / 255.0), Color(50 / 255.0, 30 / 255.0, 10 / 255.0)] + var _mhr_blue = [Color(30 / 255.0, 80 / 255.0, 180 / 255.0), Color(20 / 255.0, 50 / 255.0, 120 / 255.0), Color(10 / 255.0, 30 / 255.0, 80 / 255.0), Color(5 / 255.0, 15 / 255.0, 50 / 255.0)] + var _mhr_lightblue = [Color(170 / 255.0, 220 / 255.0, 1.0), Color(130 / 255.0, 190 / 255.0, 1.0), Color(90 / 255.0, 150 / 255.0, 220 / 255.0), Color(50 / 255.0, 100 / 255.0, 180 / 255.0)] + var _mhr_white = [Color(250 / 255.0, 250 / 255.0, 250 / 255.0), Color(220 / 255.0, 220 / 255.0, 220 / 255.0), Color(190 / 255.0, 190 / 255.0, 190 / 255.0), Color(150 / 255.0, 150 / 255.0, 150 / 255.0)] + var _shc_o = [Color(0, 180 / 255.0, 157 / 255.0), Color(0, 121 / 255.0, 102 / 255.0), Color(0, 79 / 255.0, 67 / 255.0), Color(0, 46 / 255.0, 93 / 255.0)] + var _shc_red = [Color(180 / 255.0, 40 / 255.0, 40 / 255.0), Color(130 / 255.0, 0, 0), Color(90 / 255.0, 0, 0), Color(60 / 255.0, 0, 0)] + var _shc_green = [Color(40 / 255.0, 160 / 255.0, 40 / 255.0), Color(0, 120 / 255.0, 0), Color(0, 80 / 255.0, 0), Color(0, 50 / 255.0, 0)] + var _sb_o = [Color(248 / 255.0, 219 / 255.0, 108 / 255.0), Color(225 / 255.0, 159 / 255.0, 57 / 255.0), Color(199 / 255.0, 115 / 255.0, 29 / 255.0), Color(151 / 255.0, 73 / 255.0, 9 / 255.0), Color(108 / 255.0, 43 / 255.0, 0), Color(58 / 255.0, 23 / 255.0, 11 / 255.0), Color(0, 76 / 255.0, 218 / 255.0), Color(9 / 255.0, 35 / 255.0, 105 / 255.0), Color(0, 14 / 255.0, 62 / 255.0)] + var _sb_iron = [Color(190 / 255.0, 187 / 255.0, 181 / 255.0), Color(162 / 255.0, 158 / 255.0, 150 / 255.0), Color(125 / 255.0, 123 / 255.0, 118 / 255.0), Color(77 / 255.0, 76 / 255.0, 75 / 255.0), Color(54 / 255.0, 54 / 255.0, 54 / 255.0), Color(30 / 255.0, 30 / 255.0, 30 / 255.0), Color(0, 76 / 255.0, 218 / 255.0), Color(9 / 255.0, 35 / 255.0, 105 / 255.0), Color(0, 14 / 255.0, 62 / 255.0)] + var _sb_steel = [Color(227 / 255.0, 227 / 255.0, 227 / 255.0), Color(183 / 255.0, 183 / 255.0, 183 / 255.0), Color(116 / 255.0, 116 / 255.0, 116 / 255.0), Color(77 / 255.0, 76 / 255.0, 75 / 255.0), Color(54 / 255.0, 54 / 255.0, 54 / 255.0), Color(30 / 255.0, 30 / 255.0, 30 / 255.0), Color(0, 76 / 255.0, 218 / 255.0), Color(9 / 255.0, 35 / 255.0, 105 / 255.0), Color(0, 14 / 255.0, 62 / 255.0)] _register_item("hat", { "item_name": "Hat", @@ -189,7 +189,7 @@ static func _load_all_items(): "buy_cost": 20, "sell_worth": 6, "rarity": ItemRarity.COMMON, - "colorReplacements": [{"original": _mhr_o[0], "replace": _mhr_brown[0]}, {"original": _mhr_o[1], "replace": _mhr_brown[1]}, {"original": _mhr_o[2], "replace": _mhr_brown[2]}, {"original": _mhr_o[3], "replace": _mhr_brown[3]}] + "colorReplacements": [ {"original": _mhr_o[0], "replace": _mhr_brown[0]}, {"original": _mhr_o[1], "replace": _mhr_brown[1]}, {"original": _mhr_o[2], "replace": _mhr_brown[2]}, {"original": _mhr_o[3], "replace": _mhr_brown[3]}] }) _register_item("red_hat", { "item_name": "Red hat", @@ -216,7 +216,7 @@ static func _load_all_items(): "buy_cost": 28, "sell_worth": 9, "rarity": ItemRarity.COMMON, - "colorReplacements": [{"original": _mhr_o[0], "replace": _mhr_blue[0]}, {"original": _mhr_o[1], "replace": _mhr_blue[1]}, {"original": _mhr_o[2], "replace": _mhr_blue[2]}, {"original": _mhr_o[3], "replace": _mhr_blue[3]}] + "colorReplacements": [ {"original": _mhr_o[0], "replace": _mhr_blue[0]}, {"original": _mhr_o[1], "replace": _mhr_blue[1]}, {"original": _mhr_o[2], "replace": _mhr_blue[2]}, {"original": _mhr_o[3], "replace": _mhr_blue[3]}] }) _register_item("wizards_hat", { "item_name": "Wizard's hat", @@ -230,7 +230,7 @@ static func _load_all_items(): "buy_cost": 55, "sell_worth": 18, "rarity": ItemRarity.UNCOMMON, - "colorReplacements": [{"original": _mhr_o[0], "replace": _mhr_lightblue[0]}, {"original": _mhr_o[1], "replace": _mhr_lightblue[1]}, {"original": _mhr_o[2], "replace": _mhr_lightblue[2]}, {"original": _mhr_o[3], "replace": _mhr_lightblue[3]}] + "colorReplacements": [ {"original": _mhr_o[0], "replace": _mhr_lightblue[0]}, {"original": _mhr_o[1], "replace": _mhr_lightblue[1]}, {"original": _mhr_o[2], "replace": _mhr_lightblue[2]}, {"original": _mhr_o[3], "replace": _mhr_lightblue[3]}] }) _register_item("gandolfs_hat", { "item_name": "Gandolf's Hat", @@ -244,7 +244,7 @@ static func _load_all_items(): "buy_cost": 60, "sell_worth": 20, "rarity": ItemRarity.UNCOMMON, - "colorReplacements": [{"original": _mhr_o[0], "replace": _mhr_white[0]}, {"original": _mhr_o[1], "replace": _mhr_white[1]}, {"original": _mhr_o[2], "replace": _mhr_white[2]}, {"original": _mhr_o[3], "replace": _mhr_white[3]}] + "colorReplacements": [ {"original": _mhr_o[0], "replace": _mhr_white[0]}, {"original": _mhr_o[1], "replace": _mhr_white[1]}, {"original": _mhr_o[2], "replace": _mhr_white[2]}, {"original": _mhr_o[3], "replace": _mhr_white[3]}] }) _register_item("sorcerors_hood", { @@ -272,7 +272,7 @@ static func _load_all_items(): "buy_cost": 45, "sell_worth": 14, "rarity": ItemRarity.COMMON, - "colorReplacements": [{"original": _shc_o[0], "replace": _shc_red[0]}, {"original": _shc_o[1], "replace": _shc_red[1]}, {"original": _shc_o[2], "replace": _shc_red[2]}, {"original": _shc_o[3], "replace": _shc_red[3]}] + "colorReplacements": [ {"original": _shc_o[0], "replace": _shc_red[0]}, {"original": _shc_o[1], "replace": _shc_red[1]}, {"original": _shc_o[2], "replace": _shc_red[2]}, {"original": _shc_o[3], "replace": _shc_red[3]}] }) _register_item("green_hood", { "item_name": "Green Hood", @@ -286,7 +286,7 @@ static func _load_all_items(): "buy_cost": 52, "sell_worth": 17, "rarity": ItemRarity.COMMON, - "colorReplacements": [{"original": _shc_o[0], "replace": _shc_green[0]}, {"original": _shc_o[1], "replace": _shc_green[1]}, {"original": _shc_o[2], "replace": _shc_green[2]}, {"original": _shc_o[3], "replace": _shc_green[3]}] + "colorReplacements": [ {"original": _shc_o[0], "replace": _shc_green[0]}, {"original": _shc_o[1], "replace": _shc_green[1]}, {"original": _shc_o[2], "replace": _shc_green[2]}, {"original": _shc_o[3], "replace": _shc_green[3]}] }) _register_item("high_mage_hat", { @@ -723,7 +723,7 @@ static func _load_all_items(): "buy_cost": 110, "sell_worth": 34, "rarity": ItemRarity.UNCOMMON, - "colorReplacements": [{"original": _sb_o[0], "replace": _sb_iron[0]}, {"original": _sb_o[1], "replace": _sb_iron[1]}, {"original": _sb_o[2], "replace": _sb_iron[2]}, {"original": _sb_o[3], "replace": _sb_iron[3]}, {"original": _sb_o[4], "replace": _sb_iron[4]}, {"original": _sb_o[5], "replace": _sb_iron[5]}, {"original": _sb_o[6], "replace": _sb_iron[6]}, {"original": _sb_o[7], "replace": _sb_iron[7]}, {"original": _sb_o[8], "replace": _sb_iron[8]}] + "colorReplacements": [ {"original": _sb_o[0], "replace": _sb_iron[0]}, {"original": _sb_o[1], "replace": _sb_iron[1]}, {"original": _sb_o[2], "replace": _sb_iron[2]}, {"original": _sb_o[3], "replace": _sb_iron[3]}, {"original": _sb_o[4], "replace": _sb_iron[4]}, {"original": _sb_o[5], "replace": _sb_iron[5]}, {"original": _sb_o[6], "replace": _sb_iron[6]}, {"original": _sb_o[7], "replace": _sb_iron[7]}, {"original": _sb_o[8], "replace": _sb_iron[8]}] }) _register_item("soldier_steel_helm", { "item_name": "Soldier Steel Helm", @@ -737,7 +737,7 @@ static func _load_all_items(): "buy_cost": 125, "sell_worth": 38, "rarity": ItemRarity.UNCOMMON, - "colorReplacements": [{"original": _sb_o[0], "replace": _sb_steel[0]}, {"original": _sb_o[1], "replace": _sb_steel[1]}, {"original": _sb_o[2], "replace": _sb_steel[2]}, {"original": _sb_o[3], "replace": _sb_steel[3]}, {"original": _sb_o[4], "replace": _sb_steel[4]}, {"original": _sb_o[5], "replace": _sb_steel[5]}, {"original": _sb_o[6], "replace": _sb_steel[6]}, {"original": _sb_o[7], "replace": _sb_steel[7]}, {"original": _sb_o[8], "replace": _sb_steel[8]}] + "colorReplacements": [ {"original": _sb_o[0], "replace": _sb_steel[0]}, {"original": _sb_o[1], "replace": _sb_steel[1]}, {"original": _sb_o[2], "replace": _sb_steel[2]}, {"original": _sb_o[3], "replace": _sb_steel[3]}, {"original": _sb_o[4], "replace": _sb_steel[4]}, {"original": _sb_o[5], "replace": _sb_steel[5]}, {"original": _sb_o[6], "replace": _sb_steel[6]}, {"original": _sb_o[7], "replace": _sb_steel[7]}, {"original": _sb_o[8], "replace": _sb_steel[8]}] }) _register_item("assassin_bandana", { @@ -787,7 +787,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ACCESSORY, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 1 * 20 + 17, # 17,1 + "spriteFrame": 1 * 20 + 17, # 17,1 "modifiers": {"str": 3}, "buy_cost": 250, "sell_worth": 75, @@ -800,7 +800,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 1 * 20 + 18, # 18,1 + "spriteFrame": 1 * 20 + 18, # 18,1 "modifiers": {"def": 5, "dex": 1}, "buy_cost": 180, "sell_worth": 54, @@ -813,7 +813,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 1 * 20 + 19, # 19,1 + "spriteFrame": 1 * 20 + 19, # 19,1 "modifiers": {"def": 6, "end": 1}, "buy_cost": 220, "sell_worth": 66, @@ -827,7 +827,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 10, # 10,2 + "spriteFrame": 2 * 20 + 10, # 10,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/ShoesBrown.png", "modifiers": {"def": 1}, "buy_cost": 15, @@ -841,13 +841,13 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 11, # 11,2 + "spriteFrame": 2 * 20 + 11, # 11,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/ShoesBrown.png", "modifiers": {"def": 2}, "buy_cost": 40, "sell_worth": 12, "rarity": ItemRarity.COMMON, - "weight": 1.5 # Boots are light + "weight": 1.5 # Boots are light }) _register_item("sturdy_boots", { @@ -856,7 +856,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 12, # 12,2 + "spriteFrame": 2 * 20 + 12, # 12,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/ShoesMaple.png", "modifiers": {"def": 3}, "buy_cost": 60, @@ -870,7 +870,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 13, # 13,2 + "spriteFrame": 2 * 20 + 13, # 13,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/ShoesMaple.png", "modifiers": {"def": 3, "end": 1}, "buy_cost": 80, @@ -884,7 +884,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 14, # 14,2 + "spriteFrame": 2 * 20 + 14, # 14,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/IronBoots.png", "modifiers": {"def": 4}, "buy_cost": 100, @@ -898,7 +898,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 15, # 15,2 + "spriteFrame": 2 * 20 + 15, # 15,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/IronBoots.png", "modifiers": {"def": 5}, "buy_cost": 140, @@ -912,7 +912,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.BOOTS, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 16, # 16,2 + "spriteFrame": 2 * 20 + 16, # 16,2 "equipmentPath": "res://assets/gfx/Puny-Characters/Layer 1 - Shoes/IronBoots.png", "modifiers": {"def": 6, "lck": 1}, "buy_cost": 250, @@ -927,7 +927,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ACCESSORY, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 17, # 17,2 + "spriteFrame": 2 * 20 + 17, # 17,2 "modifiers": {"end": 2, "hp": 20}, "buy_cost": 200, "sell_worth": 60, @@ -940,7 +940,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ACCESSORY, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 18, # 18,2 + "spriteFrame": 2 * 20 + 18, # 18,2 "modifiers": {"int": 2, "mp": 30}, "buy_cost": 200, "sell_worth": 60, @@ -953,7 +953,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.ACCESSORY, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 2 * 20 + 19, # 19,2 + "spriteFrame": 2 * 20 + 19, # 19,2 "modifiers": {"light_radius": 2}, "buy_cost": 150, "sell_worth": 45, @@ -967,7 +967,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 10, # 10,3 + "spriteFrame": 3 * 20 + 10, # 10,3 "modifiers": {"dmg": 5}, "buy_cost": 100, "sell_worth": 30, @@ -981,7 +981,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 11, # 11,3 + "spriteFrame": 3 * 20 + 11, # 11,3 "modifiers": {"dmg": 8, "int": 2}, "buy_cost": 250, "sell_worth": 75, @@ -994,7 +994,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 12, # 12,3 + "spriteFrame": 3 * 20 + 12, # 12,3 "modifiers": {"dmg": 15, "str": 3, "wis": 2}, "buy_cost": 1000, "sell_worth": 300, @@ -1007,7 +1007,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 13, # 13,3 + "spriteFrame": 3 * 20 + 13, # 13,3 "modifiers": {"dmg": 7, "dex": 3}, "buy_cost": 300, "sell_worth": 90, @@ -1020,7 +1020,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 14, # 14,3 + "spriteFrame": 3 * 20 + 14, # 14,3 "modifiers": {"dmg": 12, "str": 2}, "buy_cost": 400, "sell_worth": 120, @@ -1033,7 +1033,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 15, # 15,3 + "spriteFrame": 3 * 20 + 15, # 15,3 "modifiers": {"dmg": 10, "int": 3}, "buy_cost": 350, "sell_worth": 105, @@ -1046,7 +1046,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 16, # 16,3 + "spriteFrame": 3 * 20 + 16, # 16,3 "modifiers": {"dmg": 6}, "buy_cost": 120, "sell_worth": 36, @@ -1059,7 +1059,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 17, # 17,3 + "spriteFrame": 3 * 20 + 17, # 17,3 "modifiers": {"dmg": 11, "lck": 2}, "buy_cost": 380, "sell_worth": 114, @@ -1072,7 +1072,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 18, # 18,3 + "spriteFrame": 3 * 20 + 18, # 18,3 "modifiers": {"dmg": 9, "lck": 3}, "buy_cost": 320, "sell_worth": 96, @@ -1085,7 +1085,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 19, # 19,3 + "spriteFrame": 3 * 20 + 19, # 19,3 "modifiers": {"dmg": 13, "lck": 2, "str": 1}, "buy_cost": 450, "sell_worth": 135, @@ -1099,7 +1099,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 4 * 20 + 10, # 10,4 + "spriteFrame": 4 * 20 + 10, # 10,4 "modifiers": {"dmg": 6, "dex": 2}, "buy_cost": 150, "sell_worth": 45, @@ -1112,7 +1112,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 4 * 20 + 11, # 11,4 + "spriteFrame": 4 * 20 + 11, # 11,4 "modifiers": {"dmg": 5, "dex": 3}, "buy_cost": 130, "sell_worth": 39, @@ -1126,7 +1126,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, "two_handed": true, - "spriteFrame": 4 * 20 + 12, # 12,4 + "spriteFrame": 4 * 20 + 12, # 12,4 "modifiers": {"dmg": 14, "str": 2}, "buy_cost": 500, "sell_worth": 150, @@ -1139,7 +1139,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 4 * 20 + 13, # 13,4 + "spriteFrame": 4 * 20 + 13, # 13,4 "modifiers": {"dmg": 12, "str": 3}, "buy_cost": 420, "sell_worth": 126, @@ -1152,7 +1152,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 4 * 20 + 14, # 14,4 + "spriteFrame": 4 * 20 + 14, # 14,4 "modifiers": {"dmg": 10, "int": 2, "wis": 1}, "buy_cost": 350, "sell_worth": 105, @@ -1165,7 +1165,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 4 * 20 + 15, # 15,4 + "spriteFrame": 4 * 20 + 15, # 15,4 "modifiers": {"dmg": 16, "str": 2, "wis": 3}, "buy_cost": 800, "sell_worth": 240, @@ -1179,7 +1179,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.DAGGER, - "spriteFrame": 4 * 20 + 16, # 16,4 + "spriteFrame": 4 * 20 + 16, # 16,4 "modifiers": {"dmg": 3, "dex": 1}, "buy_cost": 40, "sell_worth": 12, @@ -1192,7 +1192,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.DAGGER, - "spriteFrame": 4 * 20 + 17, # 17,4 + "spriteFrame": 4 * 20 + 17, # 17,4 "modifiers": {"dmg": 4, "dex": 2, "lck": 1}, "buy_cost": 80, "sell_worth": 24, @@ -1205,7 +1205,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.DAGGER, - "spriteFrame": 4 * 20 + 18, # 18,4 + "spriteFrame": 4 * 20 + 18, # 18,4 "modifiers": {"dmg": 5, "lck": 2}, "buy_cost": 120, "sell_worth": 36, @@ -1218,7 +1218,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.DAGGER, - "spriteFrame": 4 * 20 + 19, # 19,4 + "spriteFrame": 4 * 20 + 19, # 19,4 "modifiers": {"dmg": 6, "dex": 3, "lck": 1}, "buy_cost": 180, "sell_worth": 54, @@ -1232,7 +1232,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 5 * 20 + 10, # 10,5 + "spriteFrame": 5 * 20 + 10, # 10,5 "modifiers": {"dmg": 4}, "buy_cost": 60, "sell_worth": 18, @@ -1245,7 +1245,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.AXE, - "spriteFrame": 5 * 20 + 11, # 11,5 + "spriteFrame": 5 * 20 + 11, # 11,5 "modifiers": {"dmg": 5, "str": 1}, "buy_cost": 70, "sell_worth": 21, @@ -1258,7 +1258,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.AXE, - "spriteFrame": 5 * 20 + 12, # 12,5 + "spriteFrame": 5 * 20 + 12, # 12,5 "modifiers": {"dmg": 7, "str": 1}, "buy_cost": 100, "sell_worth": 30, @@ -1271,7 +1271,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.AXE, - "spriteFrame": 5 * 20 + 13, # 13,5 + "spriteFrame": 5 * 20 + 13, # 13,5 "modifiers": {"dmg": 9, "str": 2, "lck": 1}, "buy_cost": 250, "sell_worth": 75, @@ -1284,7 +1284,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.AXE, - "spriteFrame": 5 * 20 + 14, # 14,5 + "spriteFrame": 5 * 20 + 14, # 14,5 "modifiers": {"dmg": 10, "str": 3}, "buy_cost": 300, "sell_worth": 90, @@ -1298,7 +1298,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.AXE, "two_handed": true, - "spriteFrame": 5 * 20 + 15, # 15,5 + "spriteFrame": 5 * 20 + 15, # 15,5 "modifiers": {"dmg": 13, "str": 3}, "buy_cost": 450, "sell_worth": 135, @@ -1313,7 +1313,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.STAFF, "two_handed": true, - "spriteFrame": 5 * 20 + 16, # 16,5 + "spriteFrame": 5 * 20 + 16, # 16,5 "modifiers": {"dmg": 3, "int": 2, "mp": 20}, "buy_cost": 80, "sell_worth": 24, @@ -1327,7 +1327,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.STAFF, "two_handed": true, - "spriteFrame": 5 * 20 + 17, # 17,5 + "spriteFrame": 5 * 20 + 17, # 17,5 "modifiers": {"dmg": 6, "int": 4, "mp": 40}, "buy_cost": 350, "sell_worth": 105, @@ -1341,7 +1341,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.STAFF, "two_handed": true, - "spriteFrame": 5 * 20 + 18, # 18,5 + "spriteFrame": 5 * 20 + 18, # 18,5 "modifiers": {"dmg": 6, "wis": 4, "mp": 40}, "buy_cost": 350, "sell_worth": 105, @@ -1355,7 +1355,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.STAFF, "two_handed": true, - "spriteFrame": 5 * 20 + 19, # 19,5 + "spriteFrame": 5 * 20 + 19, # 19,5 "modifiers": {"dmg": 7, "int": 3, "mp": 35}, "buy_cost": 380, "sell_worth": 114, @@ -1370,7 +1370,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.STAFF, "two_handed": true, - "spriteFrame": 6 * 20 + 10, # 10,6 + "spriteFrame": 6 * 20 + 10, # 10,6 "modifiers": {"dmg": 10, "int": 5, "wis": 3, "mp": 60}, "buy_cost": 700, "sell_worth": 210, @@ -1384,7 +1384,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SPEAR, "two_handed": true, - "spriteFrame": 6 * 20 + 11, # 11,6 + "spriteFrame": 6 * 20 + 11, # 11,6 "modifiers": {"dmg": 6, "dex": 1}, "buy_cost": 90, "sell_worth": 27, @@ -1398,7 +1398,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SPEAR, "two_handed": true, - "spriteFrame": 6 * 20 + 12, # 12,6 + "spriteFrame": 6 * 20 + 12, # 12,6 "modifiers": {"dmg": 8, "dex": 2, "lck": 1}, "buy_cost": 200, "sell_worth": 60, @@ -1412,7 +1412,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SPEAR, "two_handed": true, - "spriteFrame": 6 * 20 + 13, # 13,6 + "spriteFrame": 6 * 20 + 13, # 13,6 "modifiers": {"dmg": 9, "dex": 2, "lck": 2}, "buy_cost": 280, "sell_worth": 84, @@ -1426,7 +1426,7 @@ static func _load_all_items(): "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.SPEAR, "two_handed": true, - "spriteFrame": 6 * 20 + 14, # 14,6 + "spriteFrame": 6 * 20 + 14, # 14,6 "modifiers": {"dmg": 10, "dex": 3}, "buy_cost": 320, "sell_worth": 96, @@ -1440,7 +1440,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.MACE, - "spriteFrame": 6 * 20 + 15, # 15,6 + "spriteFrame": 6 * 20 + 15, # 15,6 "modifiers": {"dmg": 8, "str": 2}, "buy_cost": 120, "sell_worth": 36, @@ -1453,12 +1453,12 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.BOW, - "spriteFrame": 6 * 20 + 16, # 16,6 + "spriteFrame": 6 * 20 + 16, # 16,6 "modifiers": {"dmg": 4, "dex": 2}, "buy_cost": 100, "sell_worth": 30, "rarity": ItemRarity.COMMON, - "weight": 2.5 # Bows are moderate weight + "weight": 2.5 # Bows are moderate weight }) _register_item("dark_bow", { @@ -1467,7 +1467,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.BOW, - "spriteFrame": 6 * 20 + 17, # 17,6 + "spriteFrame": 6 * 20 + 17, # 17,6 "modifiers": {"dmg": 6, "dex": 3, "lck": 1}, "buy_cost": 220, "sell_worth": 66, @@ -1480,7 +1480,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.BOW, - "spriteFrame": 6 * 20 + 18, # 18,6 + "spriteFrame": 6 * 20 + 18, # 18,6 "modifiers": {"dmg": 7, "dex": 2}, "buy_cost": 180, "sell_worth": 54, @@ -1493,7 +1493,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.BOW, - "spriteFrame": 6 * 20 + 19, # 19,6 + "spriteFrame": 6 * 20 + 19, # 19,6 "modifiers": {"dmg": 8, "dex": 3}, "buy_cost": 280, "sell_worth": 84, @@ -1507,7 +1507,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, "weapon_type": Item.WeaponType.BOW, - "spriteFrame": 7 * 20 + 10, # 10,7 + "spriteFrame": 7 * 20 + 10, # 10,7 "modifiers": {"dmg": 10, "dex": 4, "lck": 2}, "buy_cost": 500, "sell_worth": 150, @@ -1520,15 +1520,15 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.AMMUNITION, - "spriteFrame": 7 * 20 + 11, # 11,7 - "quantity": 15, # Increased from 13 to 15 + "spriteFrame": 7 * 20 + 11, # 11,7 + "quantity": 15, # Increased from 13 to 15 "can_have_multiple_of": true, "modifiers": {"dmg": 2}, "buy_cost": 20, "sell_worth": 6, "rarity": ItemRarity.COMMON, - "weight": 0.1, # Very light in inventory (arrows are light!) - "drop_chance": 15.0 # Much higher drop chance = drops MUCH more often! + "weight": 0.1, # Very light in inventory (arrows are light!) + "drop_chance": 15.0 # Much higher drop chance = drops MUCH more often! }) # CONSUMABLE FOOD ITEMS (row 7) @@ -1538,8 +1538,8 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 12, # 12,7 - "weight": 0.2, # Very light consumable + "spriteFrame": 7 * 20 + 12, # 12,7 + "weight": 0.2, # Very light consumable "modifiers": {"hp": 10}, "buy_cost": 15, "sell_worth": 4, @@ -1552,7 +1552,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 13, # 13,7 + "spriteFrame": 7 * 20 + 13, # 13,7 "modifiers": {"hp": 20}, "buy_cost": 25, "sell_worth": 7, @@ -1565,7 +1565,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 14, # 14,7 + "spriteFrame": 7 * 20 + 14, # 14,7 "modifiers": {"hp": 3}, "buy_cost": 5, "sell_worth": 1, @@ -1578,7 +1578,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 15, # 15,7 + "spriteFrame": 7 * 20 + 15, # 15,7 "modifiers": {"hp": 5}, "buy_cost": 8, "sell_worth": 2, @@ -1591,7 +1591,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 16, # 16,7 + "spriteFrame": 7 * 20 + 16, # 16,7 "modifiers": {"hp": 8}, "buy_cost": 12, "sell_worth": 3, @@ -1604,7 +1604,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 17, # 17,7 + "spriteFrame": 7 * 20 + 17, # 17,7 "modifiers": {"hp": 10}, "buy_cost": 15, "sell_worth": 4, @@ -1617,7 +1617,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 18, # 18,7 + "spriteFrame": 7 * 20 + 18, # 18,7 "modifiers": {"hp": 2}, "buy_cost": 3, "sell_worth": 1, @@ -1630,7 +1630,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 7 * 20 + 19, # 19,7 + "spriteFrame": 7 * 20 + 19, # 19,7 "modifiers": {"hp": 14}, "buy_cost": 20, "sell_worth": 6, @@ -1644,7 +1644,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 13, # 13,8 + "spriteFrame": 8 * 20 + 13, # 13,8 "modifiers": {"hp": 4}, "buy_cost": 6, "sell_worth": 2, @@ -1657,21 +1657,60 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 14, # 14,8 + "spriteFrame": 8 * 20 + 14, # 14,8 "modifiers": {"hp": 7}, "buy_cost": 10, "sell_worth": 3, "rarity": ItemRarity.COMMON }) + _register_item("apple", { + "item_name": "Apple", + "description": "Restores 20 HP", + "item_type": Item.ItemType.Restoration, + "equipment_type": Item.EquipmentType.NONE, + "weapon_type": Item.WeaponType.NONE, + "spriteFrame": 8 * 20 + 10, # 10,8 + "modifiers": {"hp": 20}, + "buy_cost": 15, + "sell_worth": 5, + "rarity": ItemRarity.COMMON + }) + + _register_item("banana", { + "item_name": "Banana", + "description": "Restores 20 HP", + "item_type": Item.ItemType.Restoration, + "equipment_type": Item.EquipmentType.NONE, + "weapon_type": Item.WeaponType.NONE, + "spriteFrame": 8 * 20 + 11, # 11,8 + "modifiers": {"hp": 20}, + "buy_cost": 15, + "sell_worth": 5, + "rarity": ItemRarity.COMMON + }) + + _register_item("cherry", { + "item_name": "Cherry", + "description": "Restores 20 HP", + "item_type": Item.ItemType.Restoration, + "equipment_type": Item.EquipmentType.NONE, + "weapon_type": Item.WeaponType.NONE, + "spriteFrame": 8 * 20 + 12, # 12,8 + "modifiers": {"hp": 20}, + "buy_cost": 15, + "sell_worth": 5, + "rarity": ItemRarity.COMMON + }) + _register_item("healing_potion", { "item_name": "Healing Potion", "description": "Restores 50 HP", "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 15, # 15,8 - "weight": 0.3, # Light potion + "spriteFrame": 8 * 20 + 15, # 15,8 + "weight": 0.3, # Light potion "modifiers": {"hp": 50}, "buy_cost": 50, "sell_worth": 15, @@ -1684,8 +1723,8 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 16, # 16,8 - "weight": 0.3, # Light potion + "spriteFrame": 8 * 20 + 16, # 16,8 + "weight": 0.3, # Light potion "modifiers": {"hp": 75, "mp": 75}, "buy_cost": 100, "sell_worth": 30, @@ -1698,10 +1737,10 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 17, # 17,8 - "weight": 0.3, # Light potion - "modifiers": {"dodge_chance": 0.15}, # +15% dodge chance (temporary) - "duration": 60.0, # 60 seconds + "spriteFrame": 8 * 20 + 17, # 17,8 + "weight": 0.3, # Light potion + "modifiers": {"dodge_chance": 0.15}, # +15% dodge chance (temporary) + "duration": 60.0, # 60 seconds "buy_cost": 80, "sell_worth": 24, "rarity": ItemRarity.CONSUMABLE @@ -1713,8 +1752,8 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 18, # 18,8 - "weight": 0.3, # Light potion + "spriteFrame": 8 * 20 + 18, # 18,8 + "weight": 0.3, # Light potion "modifiers": {"mp": 50}, "buy_cost": 40, "sell_worth": 12, @@ -1727,10 +1766,10 @@ static func _load_all_items(): "item_type": Item.ItemType.Restoration, "equipment_type": Item.EquipmentType.NONE, "weapon_type": Item.WeaponType.NONE, - "spriteFrame": 8 * 20 + 19, # 19,8 - "weight": 0.3, # Light potion - "modifiers": {"res_all": 25}, # +25% to all resistances - "duration": 120.0, # 120 seconds + "spriteFrame": 8 * 20 + 19, # 19,8 + "weight": 0.3, # Light potion + "modifiers": {"res_all": 25}, # +25% to all resistances + "duration": 120.0, # 120 seconds "buy_cost": 120, "sell_worth": 36, "rarity": ItemRarity.CONSUMABLE @@ -1739,11 +1778,11 @@ static func _load_all_items(): # SPELLBOOKS (row 11, columns 13-14) # Sprite 233 = 11 * 20 + 13 — same base as Tome of Healing, blue colorReplacements var _tf_o = [ - Color(225.0/255.0, 130.0/255.0, 137.0/255.0), - Color(174.0/255.0, 108.0/255.0, 55.0/255.0), - Color(245.0/255.0, 183.0/255.0, 132.0/255.0), - Color(130.0/255.0, 60.0/255.0, 61.0/255.0), - Color(197.0/255.0, 151.0/255.0, 130.0/255.0) + Color(225.0 / 255.0, 130.0 / 255.0, 137.0 / 255.0), + Color(174.0 / 255.0, 108.0 / 255.0, 55.0 / 255.0), + Color(245.0 / 255.0, 183.0 / 255.0, 132.0 / 255.0), + Color(130.0 / 255.0, 60.0 / 255.0, 61.0 / 255.0), + Color(197.0 / 255.0, 151.0 / 255.0, 130.0 / 255.0) ] var _tf_blue = [ Color(0.35, 0.6, 0.95), @@ -1758,7 +1797,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.SPELLBOOK, - "spriteFrame": 11 * 20 + 13, # 233 + "spriteFrame": 11 * 20 + 13, # 233 "modifiers": {}, "buy_cost": 100, "sell_worth": 30, @@ -1780,24 +1819,24 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.SPELLBOOK, - "spriteFrame": 11 * 20 + 14, # 234 + "spriteFrame": 11 * 20 + 14, # 234 "modifiers": {}, "buy_cost": 100, "sell_worth": 30, "weight": 1.5, "rarity": ItemRarity.UNCOMMON, "colorReplacements": [ - {"old_color": Color(1.0, 1.0, 1.0), "new_color": Color(1.0, 0.8, 0.6)} # Warm orange tint for fire + {"old_color": Color(1.0, 1.0, 1.0), "new_color": Color(1.0, 0.8, 0.6)} # Warm orange tint for fire ] }) # Tome of Healing - frame 233 (11*20+13), green colorReplacements var _th_o = [ - Color(225.0/255.0, 130.0/255.0, 137.0/255.0), - Color(174.0/255.0, 108.0/255.0, 55.0/255.0), - Color(245.0/255.0, 183.0/255.0, 132.0/255.0), - Color(130.0/255.0, 60.0/255.0, 61.0/255.0), - Color(197.0/255.0, 151.0/255.0, 130.0/255.0) + Color(225.0 / 255.0, 130.0 / 255.0, 137.0 / 255.0), + Color(174.0 / 255.0, 108.0 / 255.0, 55.0 / 255.0), + Color(245.0 / 255.0, 183.0 / 255.0, 132.0 / 255.0), + Color(130.0 / 255.0, 60.0 / 255.0, 61.0 / 255.0), + Color(197.0 / 255.0, 151.0 / 255.0, 130.0 / 255.0) ] var _th_green = [ Color(0.35, 0.85, 0.4), @@ -1834,7 +1873,7 @@ static func _load_all_items(): "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.OFFHAND, "weapon_type": Item.WeaponType.BOMB, - "spriteFrame": 199, # 9 * 20 + 19 + "spriteFrame": 199, # 9 * 20 + 19 "quantity": 1, "can_have_multiple_of": true, "modifiers": {}, @@ -1875,7 +1914,7 @@ static func create_item(item_id: String) -> Item: item.quantity = item_data.get("quantity", 1) item.can_have_multiple_of = item_data.get("can_have_multiple_of", false) item.duration = item_data.get("duration", 0.0) - item.weight = item_data.get("weight", 1.0) # Default weight 1.0 + item.weight = item_data.get("weight", 1.0) # Default weight 1.0 # spritePath defaults to items_n_shit.png in Item class, which is correct # spriteFrames defaults to Vector2i(20,14) in Item class, which is correct @@ -1888,7 +1927,7 @@ static func create_item(item_id: String) -> Item: # Remove item_id from data (internal use only) item_data.erase("item_id") - item_data.erase("rarity") # Remove rarity (internal use only) + item_data.erase("rarity") # Remove rarity (internal use only) return item @@ -1945,6 +1984,106 @@ static func get_random_item() -> Item: return get_random_item_by_rarity(rarity) +# Get random consumable items (arrows, bombs, restoration items) for enemy drops +static func get_random_consumable_drop() -> Item: + _initialize() + + # Prioritize consumables: 70% CONSUMABLE rarity, 30% Restoration items + var roll = randf() + + if roll < 0.7: + # CONSUMABLE rarity items (arrows, bombs, etc.) + return get_random_item_by_rarity(ItemRarity.CONSUMABLE) + else: + # Restoration items (food) - get from COMMON rarity with Restoration type + var candidates = [] + var weights = [] + var total_weight = 0.0 + + for item_id in item_definitions.keys(): + var item_data = item_definitions[item_id] + if item_data.has("rarity") and item_data["rarity"] == ItemRarity.COMMON: + if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Restoration: + candidates.append(item_id) + var drop_chance = item_data.get("drop_chance", 1.0) + weights.append(drop_chance) + total_weight += drop_chance + + if candidates.is_empty(): + # Fallback to CONSUMABLE rarity + return get_random_item_by_rarity(ItemRarity.CONSUMABLE) + + # Weighted random selection + var weight_roll = randf() * total_weight + var cumulative = 0.0 + for i in range(candidates.size()): + cumulative += weights[i] + if weight_roll <= cumulative: + return create_item(candidates[i]) + + # Fallback + return create_item(candidates[candidates.size() - 1]) + +# Get random equipment items (much rarer than consumables) +static func get_random_equipment_drop() -> Item: + _initialize() + + # Equipment: 70% common, 25% uncommon, 4% rare, 1% epic + var roll = randf() + var rarity: ItemRarity + + if roll < 0.7: + rarity = ItemRarity.COMMON + elif roll < 0.95: + rarity = ItemRarity.UNCOMMON + elif roll < 0.99: + rarity = ItemRarity.RARE + else: + rarity = ItemRarity.EPIC + + # Filter to only Equippable items of the selected rarity + var candidates = [] + var weights = [] + var total_weight = 0.0 + + for item_id in item_definitions.keys(): + var item_data = item_definitions[item_id] + if item_data.has("rarity") and item_data["rarity"] == rarity: + # Only include Equippable items (not consumables or restoration) + if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Equippable: + candidates.append(item_id) + var drop_chance = item_data.get("drop_chance", 1.0) + weights.append(drop_chance) + total_weight += drop_chance + + if candidates.is_empty(): + # Fallback: try lower rarity equipment + if rarity != ItemRarity.COMMON: + # Try common equipment as fallback + for item_id in item_definitions.keys(): + var item_data = item_definitions[item_id] + if item_data.has("rarity") and item_data["rarity"] == ItemRarity.COMMON: + if item_data.has("item_type") and item_data["item_type"] == Item.ItemType.Equippable: + candidates.append(item_id) + var drop_chance = item_data.get("drop_chance", 1.0) + weights.append(drop_chance) + total_weight += drop_chance + + if candidates.is_empty(): + # Last resort: return any common item + return get_random_item_by_rarity(ItemRarity.COMMON) + + # Weighted random selection + var weight_roll = randf() * total_weight + var cumulative = 0.0 + for i in range(candidates.size()): + cumulative += weights[i] + if weight_roll <= cumulative: + return create_item(candidates[i]) + + # Fallback + return create_item(candidates[candidates.size() - 1]) + # Get random items for enemies (weighted towards common/uncommon) static func get_random_enemy_drop() -> Item: _initialize() diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 7240e62..5ef2352 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -86,6 +86,10 @@ func _ready(): # Setup sprite based on loot type (call after all properties are set) call_deferred("_setup_sprite") + # CRITICAL: Duplicate sprite material after setup so it isn't shared between loot instances + # This must be called after _setup_sprite because materials may be applied there (e.g., ItemDatabase.apply_item_colors_to_sprite) + call_deferred("_duplicate_sprite_material") + # Setup collision shape based on loot type call_deferred("_setup_collision_shape") @@ -187,6 +191,12 @@ func _setup_collision_shape(): collision_shape.shape = circle_shape +func _duplicate_sprite_material(): + # Duplicate sprite material so it isn't shared between loot instances + # This prevents material state from being shared (e.g., colorization, tint effects) + if sprite and sprite.material: + sprite.material = sprite.material.duplicate() + func _create_quantity_badge(quantity: int): # Create a label to show the quantity quantity_badge = Label.new() @@ -515,19 +525,40 @@ func _process_pickup_on_server(player: Node): LootType.APPLE: if sfx_potion_collect: sfx_potion_collect.play() - # Heal player - var actual_heal = 0.0 - if player.has_method("heal"): - actual_heal = heal_amount - player.heal(heal_amount) - # Show floating text with item graphic and heal amount + + # Create Item instance and add to inventory instead of directly healing + var apple_item = ItemDatabase.create_item("apple") + if apple_item and player.character_stats: + var was_encumbered = player.character_stats.is_over_encumbered() + player.character_stats.add_item(apple_item) + if not was_encumbered and player.character_stats.is_over_encumbered(): + if player.has_method("show_floating_status"): + player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) + + # Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply) + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var owner_id = player.get_multiplayer_authority() + if owner_id != 1 and owner_id != multiplayer.get_unique_id(): + var inv_data: Array = [] + for inv_item in player.character_stats.inventory: + inv_data.append(inv_item.save() if inv_item else null) + var equip_data: Dictionary = {} + for slot_name in player.character_stats.equipment.keys(): + var eq = player.character_stats.equipment[slot_name] + equip_data[slot_name] = eq.save() if eq else null + if player.has_method("_apply_inventory_and_equipment_from_server"): + player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) + + # Show floating text with item name (uppercase) var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) + var display_text = "APPLE" + var text_color = Color.GREEN + _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 10, player.get_multiplayer_authority()) + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority()) self.visible = false @@ -538,19 +569,40 @@ func _process_pickup_on_server(player: Node): LootType.BANANA: if sfx_banana_collect: sfx_banana_collect.play() - # Heal player - var actual_heal = 0.0 - if player.has_method("heal"): - actual_heal = heal_amount - player.heal(heal_amount) - # Show floating text with item graphic and heal amount + + # Create Item instance and add to inventory instead of directly healing + var banana_item = ItemDatabase.create_item("banana") + if banana_item and player.character_stats: + var was_encumbered = player.character_stats.is_over_encumbered() + player.character_stats.add_item(banana_item) + if not was_encumbered and player.character_stats.is_over_encumbered(): + if player.has_method("show_floating_status"): + player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) + + # Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply) + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var owner_id = player.get_multiplayer_authority() + if owner_id != 1 and owner_id != multiplayer.get_unique_id(): + var inv_data: Array = [] + for inv_item in player.character_stats.inventory: + inv_data.append(inv_item.save() if inv_item else null) + var equip_data: Dictionary = {} + for slot_name in player.character_stats.equipment.keys(): + var eq = player.character_stats.equipment[slot_name] + equip_data[slot_name] = eq.save() if eq else null + if player.has_method("_apply_inventory_and_equipment_from_server"): + player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) + + # Show floating text with item name (uppercase) var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) + var display_text = "BANANA" + var text_color = Color.GREEN + _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 11, player.get_multiplayer_authority()) + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority()) self.visible = false @@ -561,19 +613,40 @@ func _process_pickup_on_server(player: Node): LootType.CHERRY: if sfx_banana_collect: sfx_banana_collect.play() - # Heal player - var actual_heal = 0.0 - if player.has_method("heal"): - actual_heal = heal_amount - player.heal(heal_amount) - # Show floating text with item graphic and heal amount + + # Create Item instance and add to inventory instead of directly healing + var cherry_item = ItemDatabase.create_item("cherry") + if cherry_item and player.character_stats: + var was_encumbered = player.character_stats.is_over_encumbered() + player.character_stats.add_item(cherry_item) + if not was_encumbered and player.character_stats.is_over_encumbered(): + if player.has_method("show_floating_status"): + player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) + + # Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply) + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var owner_id = player.get_multiplayer_authority() + if owner_id != 1 and owner_id != multiplayer.get_unique_id(): + var inv_data: Array = [] + for inv_item in player.character_stats.inventory: + inv_data.append(inv_item.save() if inv_item else null) + var equip_data: Dictionary = {} + for slot_name in player.character_stats.equipment.keys(): + var eq = player.character_stats.equipment[slot_name] + equip_data[slot_name] = eq.save() if eq else null + if player.has_method("_apply_inventory_and_equipment_from_server"): + player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) + + # Show floating text with item name (uppercase) var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) + var display_text = "CHERRY" + var text_color = Color.GREEN + _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 12, player.get_multiplayer_authority()) + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority()) self.visible = false diff --git a/src/scripts/matchbox_client.gd b/src/scripts/matchbox_client.gd index 1394888..795b484 100644 --- a/src/scripts/matchbox_client.gd +++ b/src/scripts/matchbox_client.gd @@ -353,6 +353,20 @@ func _handle_new_peer(uuid: String): # Client: we don't assign peer IDs, the host does # Just store the UUID for now, peer ID will come via Signal message log_print("MatchboxClient: Client received NewPeer for UUID: " + uuid + " (waiting for host to assign peer ID)") + + # If we're in reconnection mode and don't have a host connection yet, this might be the host reconnecting + # Check if we previously had a host (peer ID 1) but lost it + var had_host = false + for stored_uuid in peer_uuid_to_id: + if peer_uuid_to_id[stored_uuid] == 1: + had_host = true + break + + # If we don't have a host connection and we're waiting for reconnection, this could be the host + if not had_host and not peer_connections.has(1): + log_print("MatchboxClient: Client received NewPeer while waiting for host reconnection - this might be the host reconnecting") + # Don't do anything yet - wait for host to assign peer ID via Signal + # But we can clear any reconnection flags in NetworkManager if needed func _handle_peer_left_uuid(uuid: String): if uuid.is_empty(): @@ -585,6 +599,14 @@ func _handle_signal_message(peer_id: int, signal_data: Dictionary): _handle_signal_message_dict(queued_msg) queued_signaling_messages.clear() + # If we're in reconnection mode, clear it since we've successfully reconnected + var network_manager = get_parent() + if network_manager and "reconnection_attempting" in network_manager: + if network_manager.reconnection_attempting: + log_print("MatchboxClient: Host reconnected and assigned peer ID - clearing reconnection state") + network_manager.reconnection_attempting = false + network_manager.reconnection_timer = 0.0 + return # Handle WebRTC signaling messages (offer, answer, ice-candidate) diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index a905ba4..8649e91 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -518,6 +518,15 @@ func _on_matchbox_connected(was_reconnecting: bool = false): var chat_ui = game_world.get_node_or_null("ChatUI") if chat_ui and chat_ui.has_method("add_colorful_local_message"): chat_ui.add_colorful_local_message("System", "Matchbox connection re-established!") + + # For joiners: if we're connected to Matchbox but in reconnection mode, + # we're waiting for the host to reconnect and assign our peer ID + # Don't clear reconnection state yet - wait for peer ID assignment + if not is_hosting and reconnection_attempting and matchbox_client: + if matchbox_client.is_network_connected: + log_print("NetworkManager: Joiner connected to Matchbox, waiting for host to reconnect and assign peer ID...") + # Don't clear reconnection_attempting yet - wait for peer ID assignment + # The matchbox_client will clear it when peer ID is assigned func _on_matchbox_webrtc_ready(): log_print("NetworkManager: WebRTC mesh is ready") diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 4ad9e35..d2b98c2 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -8,8 +8,8 @@ var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/sta @export var move_speed: float = 80.0 @export var grab_range: float = 20.0 -@export var base_throw_force: float = 80.0 # Base throw force (reduced from 150), scales with STR -@export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) +@export var base_throw_force: float = 80.0 # Base throw force (reduced from 150), scales with STR +@export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) # Network identity var peer_id: int = 1 @@ -59,9 +59,9 @@ var controls_disabled: bool = false # True when player has reached exit and cont # Being held state var being_held_by: Node = null -var grabbed_by_enemy_hand: Node = null # Set when enemy hand grabs (snatch); locks movement until release -var enemy_hand_grab_knockback_time: float = 0.0 # Timer for initial knockback when grabbed -const ENEMY_HAND_GRAB_KNOCKBACK_DURATION: float = 0.15 # Short knockback duration before moving to hand +var grabbed_by_enemy_hand: Node = null # Set when enemy hand grabs (snatch); locks movement until release +var enemy_hand_grab_knockback_time: float = 0.0 # Timer for initial knockback when grabbed +const ENEMY_HAND_GRAB_KNOCKBACK_DURATION: float = 0.15 # Short knockback duration before moving to hand var struggle_time: float = 0.0 var struggle_threshold: float = 0.8 # Seconds to break free var struggle_direction: Vector2 = Vector2.ZERO @@ -95,13 +95,13 @@ var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse anim var original_sprite_tints: Dictionary = {} # Store original tint values for restoration var spell_incantation_played: bool = false # Track if incantation sound has been played var current_spell_element: String = "fire" # "fire" or "healing" for cursor/tint -var burn_debuff_timer: float = 0.0 # Timer for burn debuff -var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds -var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second -var burn_debuff_visual: Node2D = null # Visual indicator for burn debuff -var burn_damage_timer: float = 0.0 # Timer for burn damage ticks +var burn_debuff_timer: float = 0.0 # Timer for burn debuff +var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds +var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second +var burn_debuff_visual: Node2D = null # Visual indicator for burn debuff +var burn_damage_timer: float = 0.0 # Timer for burn damage ticks var movement_lock_timer: float = 0.0 # Lock movement when bow is released or after casting spell -const SPELL_CAST_LOCK_DURATION: float = 0.28 # Lock in place briefly after casting a spell +const SPELL_CAST_LOCK_DURATION: float = 0.28 # Lock in place briefly after casting a spell var direction_lock_timer: float = 0.0 # Lock facing direction when attacking var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack var damage_direction_lock_timer: float = 0.0 # Lock facing when taking damage (enemies/players) @@ -111,7 +111,7 @@ var shield_block_cooldown_duration: float = 1.2 # Seconds before can block again var shield_block_direction: Vector2 = Vector2.DOWN # Facing direction when we started blocking (locked) var was_shielding_last_frame: bool = false # For detecting shield activate transition var empty_bow_shot_attempts: int = 0 -var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) +var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile @@ -120,6 +120,8 @@ var flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile +var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch +var attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits) var blood_scene = preload("res://scenes/blood_clot.tscn") # Simulated Z-axis for height (when thrown) @@ -129,16 +131,17 @@ var gravity_z: float = 500.0 # Gravity pulling down (scaled for 1x scale) var is_airborne: bool = false # Spawn fall-down: hidden at start, fall from high Z, land with DIE+concussion, then stand up -const SPAWN_FALL_INITIAL_Z: float = 700.0 # High enough to start off-screen (y_offset = -350) -const SPAWN_FALL_GRAVITY: float = 180.0 # Slower fall so descent is visible (~2.8s from 700) -const SPAWN_LANDING_BOUNCE_GRAVITY: float = 420.0 # Heavier gravity during bounce so it's snappier -const SPAWN_LANDING_BOUNCE_UP: float = 95.0 # Upward velocity on first impact for a noticeable bounce -const SPAWN_LANDING_LAND_DURATION: float = 0.85 # Time in LAND (and concussion) before switching to STAND -const SPAWN_LANDING_STAND_DURATION: float = 0.16 # STAND anim duration (40+40+40+40 ms) before allow control +const SPAWN_FALL_INITIAL_Z: float = 700.0 # High enough to start off-screen (y_offset = -350) +const SPAWN_FALL_GRAVITY: float = 180.0 # Slower fall so descent is visible (~2.8s from 700) +const SPAWN_LANDING_BOUNCE_GRAVITY: float = 420.0 # Heavier gravity during bounce so it's snappier +const SPAWN_LANDING_BOUNCE_UP: float = 95.0 # Upward velocity on first impact for a noticeable bounce +const SPAWN_LANDING_LAND_DURATION: float = 0.85 # Time in LAND (and concussion) before switching to STAND +const SPAWN_LANDING_STAND_DURATION: float = 0.16 # STAND anim duration (40+40+40+40 ms) before allow control var spawn_landing: bool = false var spawn_landing_landed: bool = false var spawn_landing_bounced: bool = false -var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling +var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling +var has_seen_exit_this_level: bool = false # Track if player has seen exit notification for current level # Components # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites @@ -196,7 +199,7 @@ var coins: int: character_stats.coin = value # Key inventory -var keys: int = 0 # Number of keys the player has +var keys: int = 0 # Number of keys the player has # Animation system enum Direction { @@ -237,7 +240,7 @@ const ANIMATIONS = { }, "PUNCH": { "frames": [16, 17, 18], - "frameDurations": [50, 70, 100], + "frameDurations": [60, 90, 120], "loop": false, "nextAnimation": "IDLE" }, @@ -344,8 +347,8 @@ const ANIMATIONS = { "nextAnimation": null }, "STAND": { - "frames": [23,24,22,1], - "frameDurations": [40,40,40,40], + "frames": [23, 24, 22, 1], + "frameDurations": [40, 40, 40, 40], "loop": false, "nextAnimation": "IDLE" } @@ -379,14 +382,14 @@ func _ready(): spawn_landing = true spawn_landing_landed = false spawn_landing_bounced = false - spawn_landing_visible_shown = true # Already visible + spawn_landing_visible_shown = true # Already visible position_z = SPAWN_FALL_INITIAL_Z velocity_z = 0.0 is_airborne = true if cone_light: cone_light.visible = is_local_player if point_light: - point_light.visible = is_local_player + point_light.visible = true # Show point light for all joiners (cone is local-only) elif is_local_player: # Local players (initial spawn only): hide until right before fall-from-sky visible = false @@ -407,9 +410,8 @@ func _ready(): visible = true spawn_landing = false if cone_light: - cone_light.visible = false - if point_light: - point_light.visible = false + cone_light.visible = false # Don't show other players' cone lights + # point_light stays visible for other players # Set respawn point to starting position respawn_point = global_position @@ -449,7 +451,7 @@ func _ready(): # Set up cone light blend mode, texture, initial rotation, and spread if cone_light: _update_cone_light_rotation() - _update_cone_light_spread() # This calls _create_cone_light_texture() + _update_cone_light_spread() # This calls _create_cone_light_texture() # Wait before allowing RPCs to ensure player is fully spawned on all clients # This prevents "Node not found" errors when RPCs try to resolve node paths @@ -640,7 +642,7 @@ func _initialize_character_stats(): if game_world and "dungeon_seed" in game_world and game_world.dungeon_seed != 0: session_seed = game_world.dungeon_seed else: - session_seed = Time.get_ticks_msec() # Different each game session (for single-player) + session_seed = Time.get_ticks_msec() # Different each game session (for single-player) # Mark that we need to re-initialize appearance when dungeon_seed becomes available if multiplayer.has_multiplayer_peer(): set_meta("needs_appearance_reset", true) @@ -655,17 +657,17 @@ func _reinitialize_appearance_with_seed(_seed_value: int): # CRITICAL: Only the authority should re-initialize appearance! # Non-authority players will receive appearance via race/equipment sync if not is_multiplayer_authority(): - remove_meta("needs_appearance_reset") # Clear flag even if we skip - return # Non-authority will receive appearance via sync + remove_meta("needs_appearance_reset") # Clear flag even if we skip + return # Non-authority will receive appearance via sync var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or game_world.dungeon_seed == 0: - return # Still no seed, skip + return # Still no seed, skip # Only re-initialize if this player was spawned before dungeon_seed was available # Check if appearance needs to be reset (set in _initialize_character_stats) if not has_meta("needs_appearance_reset"): - return # Appearance was already initialized with correct seed, skip + return # Appearance was already initialized with correct seed, skip # Ensure character_stats exists before trying to modify appearance if not character_stats: @@ -688,7 +690,7 @@ func _reinitialize_appearance_with_seed(_seed_value: int): "maxmp": character_stats.maxmp, "kills": character_stats.kills, "coin": character_stats.coin, - "exp": character_stats.xp, # CharacterStats uses 'xp' not 'exp' + "exp": character_stats.xp, # CharacterStats uses 'xp' not 'exp' "level": character_stats.level } # Deep copy equipment @@ -726,7 +728,7 @@ func _reinitialize_appearance_with_seed(_seed_value: int): character_stats.maxmp = saved_stats.maxmp character_stats.kills = saved_stats.kills character_stats.coin = saved_stats.coin - character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp' + character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp' character_stats.level = saved_stats.level # Restore equipment (but Elf starting equipment will be re-added by _setup_player_appearance) @@ -774,7 +776,7 @@ func _randomize_stats(): character_stats.baseStats.dex += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.lck += 2 - character_stats.baseStats.per += 4 # Highest perception for trap detection + character_stats.baseStats.per += 4 # Highest perception for trap detection "Human": # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK, Lower PER character_stats.baseStats.str -= 2 @@ -789,15 +791,25 @@ func _randomize_stats(): func _setup_player_appearance(): # Randomize appearance - players spawn "bare" (naked, no equipment) # But with randomized hair, facial hair, eyes, etc. - # Ensure character_stats exists before setting appearance if not character_stats: LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) return - # Randomize race first (affects appearance constraints and stats) + # Use race from select screen if set (local player only); otherwise randomize (affects appearance and stats) var races = ["Dwarf", "Elf", "Human"] - var selected_race = races[appearance_rng.randi() % races.size()] + var selected_race: String + var gs_race_read: String = "" + if is_local_player: + var gs = get_node_or_null("/root/GameState") + if gs: + gs_race_read = gs.selected_race + if gs.selected_race != "" and gs.selected_race in races: + selected_race = gs.selected_race + if selected_race.is_empty(): + selected_race = races[appearance_rng.randi() % races.size()] + # Log what joiner/local player was made (authority runs this; joiner's client runs it for joiner's player) + print("Player ", name, " _setup_player_appearance: peer_id=", peer_id, " is_local_player=", is_local_player, " is_authority=", is_multiplayer_authority(), " GameState.selected_race='", gs_race_read, "' -> USING race='", selected_race, "'") character_stats.setRace(selected_race) # Randomize stats AFTER race is set (race affects stat modifiers) @@ -813,23 +825,38 @@ func _setup_player_appearance(): character_stats.equipment["offhand"] = starting_arrows print("Elf player ", name, " spawned with short bow and 3 arrows") - # Give Dwarf race starting bomb + # Give Dwarf race starting bomb + debug weapons in inventory (axe, dagger/knife, sword) if selected_race == "Dwarf": var starting_bomb = ItemDatabase.create_item("bomb") if starting_bomb: - starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start + starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start character_stats.equipment["offhand"] = starting_bomb - print("Dwarf player ", name, " spawned with 5 bombs") + var debug_axe = ItemDatabase.create_item("axe") + if debug_axe: + character_stats.add_item(debug_axe) + var debug_dagger = ItemDatabase.create_item("knife") + if debug_dagger: + character_stats.add_item(debug_dagger) + var debug_sword = ItemDatabase.create_item("short_sword") + if debug_sword: + character_stats.add_item(debug_sword) + print("Dwarf player ", name, " spawned with 5 bombs and debug axe/dagger/sword in inventory") - # Give Human race (Wizard) starting spellbook (Tome of Flames) and Hat + # Give Human race (Wizard) starting spellbook (Tome of Flames), Tome of Healing, Tome of Frostspike, and Hat if selected_race == "Human": var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: character_stats.equipment["offhand"] = starting_tome + var tome_healing = ItemDatabase.create_item("tome_of_healing") + if tome_healing: + character_stats.add_item(tome_healing) + var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike") + if tome_frostspike: + character_stats.add_item(tome_frostspike) var starting_hat = ItemDatabase.create_item("hat") if starting_hat: character_stats.equipment["headgear"] = starting_hat - print("Human player ", name, " spawned with Tome of Flames and Hat") + print("Human player ", name, " spawned with Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat") # Randomize skin (human only for players) # Weighted random: Human1 has highest chance, Human7 has lowest chance @@ -871,7 +898,7 @@ func _setup_player_appearance(): Color(0.2, 0.2, 0.9), # Bright blue Color(0.9, 0.4, 0.6), # Hot pink Color(0.5, 0.2, 0.8), # Deep purple - Color(0.9, 0.6, 0.1) # Amber + Color(0.9, 0.6, 0.1) # Amber ] character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) @@ -920,7 +947,7 @@ func _setup_player_appearance(): Color(0.2, 0.2, 0.9), # Bright blue Color(0.9, 0.4, 0.6), # Hot pink Color(0.5, 0.2, 0.8), # Deep purple - Color(0.9, 0.6, 0.1) # Amber + Color(0.9, 0.6, 0.1) # Amber ] if appearance_rng.randf() < 0.75: # 75% chance for white character_stats.setEyeColor(white_color) @@ -944,7 +971,7 @@ func _setup_player_appearance(): Color(1.0, 0.5, 0.8), # Pink Color(0.9, 0.2, 0.2), # Red Color(0.9, 0.9, 0.9), # White - Color(0.6, 0.2, 0.9) # Magenta + Color(0.6, 0.2, 0.9) # Magenta ] if eyelash_style > 0: character_stats.setEyelashColor(eyelash_colors[appearance_rng.randi() % eyelash_colors.size()]) @@ -954,7 +981,7 @@ func _setup_player_appearance(): "Elf": # Elf: always gets elf ears (ElfEars1 to 7 based on skin number) # skin_index is 0-6 (Human1-7), ear styles are 1-7 (ElfEars1-7) - var elf_ear_style = skin_index + 1 # Convert 0-6 to 1-7 + var elf_ear_style = skin_index + 1 # Convert 0-6 to 1-7 character_stats.setEars(elf_ear_style) _: # Other races: no ears @@ -1310,8 +1337,8 @@ func _apply_weapon_color_replacements(sprite: Sprite2D, item: Item) -> void: var shader_material = sprite.material as ShaderMaterial # Staff colors that should be replaced on the weapon sprite var staff_colors = [ - Color(209/255.0, 142/255.0, 54/255.0), - Color(192/255.0, 112/255.0, 31/255.0) + Color(209 / 255.0, 142 / 255.0, 54 / 255.0), + Color(192 / 255.0, 112 / 255.0, 31 / 255.0) ] var replacement_index = 0 @@ -1352,13 +1379,14 @@ func _on_character_changed(_char: CharacterStats): for slot_name in character_stats.equipment.keys(): var item = character_stats.equipment[slot_name] if item: - equipment_data[slot_name] = item.save() # Serialize item data + equipment_data[slot_name] = item.save() # Serialize item data else: equipment_data[slot_name] = null _rpc_to_ready_peers("_sync_equipment", [equipment_data]) # ALWAYS sync race and base stats to all clients (for proper display) # This ensures new clients get appearance data even if they connect after initial setup + print("Player ", name, " (authority) SENDING _sync_race_and_stats to all peers: race='", character_stats.race, "'") _rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()]) # Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players @@ -1687,28 +1715,28 @@ func _set_animation(anim_name: String): func _direction_to_angle(direction: int) -> float: match direction: Direction.DOWN: - return PI / 2.0 # 90 degrees + return PI / 2.0 # 90 degrees Direction.DOWN_RIGHT: - return PI / 4.0 # 45 degrees + return PI / 4.0 # 45 degrees Direction.RIGHT: - return 0.0 # 0 degrees + return 0.0 # 0 degrees Direction.UP_RIGHT: - return -PI / 4.0 # -45 degrees + return -PI / 4.0 # -45 degrees Direction.UP: - return -PI / 2.0 # -90 degrees + return -PI / 2.0 # -90 degrees Direction.UP_LEFT: - return -3.0 * PI / 4.0 # -135 degrees + return -3.0 * PI / 4.0 # -135 degrees Direction.LEFT: - return PI # 180 degrees + return PI # 180 degrees Direction.DOWN_LEFT: - return 3.0 * PI / 4.0 # 135 degrees + return 3.0 * PI / 4.0 # 135 degrees _: - return PI / 2.0 # Default to DOWN + return PI / 2.0 # Default to DOWN # Update cone light rotation based on player's facing direction func _update_cone_light_rotation(): if cone_light: - cone_light.rotation = _direction_to_angle(current_direction)+(PI/2) + cone_light.rotation = _direction_to_angle(current_direction) + (PI / 2) # Create a cone-shaped light texture programmatically # Creates a directional cone texture that extends forward and fades to the sides @@ -1724,9 +1752,9 @@ func _create_cone_light_texture(): var max_distance = texture_size / 2.0 # Cone parameters (these control the shape) - var cone_angle_rad = deg_to_rad(cone_light_angle) # Convert to radians + var cone_angle_rad = deg_to_rad(cone_light_angle) # Convert to radians var half_cone = cone_angle_rad / 2.0 - var forward_dir = Vector2(0, -1) # Pointing up in texture space (will be rotated by light rotation) + var forward_dir = Vector2(0, -1) # Pointing up in texture space (will be rotated by light rotation) for x in range(texture_size): for y in range(texture_size): @@ -1741,8 +1769,8 @@ func _create_cone_light_texture(): # Calculate angle from forward direction # forward_dir is (0, -1) which has angle -PI/2 # We want to find the angle difference - var pixel_angle = dir.angle() # Angle of pixel direction - var forward_angle = forward_dir.angle() # Angle of forward direction (-PI/2) + var pixel_angle = dir.angle() # Angle of pixel direction + var forward_angle = forward_dir.angle() # Angle of forward direction (-PI/2) # Calculate angle difference (wrapped to -PI to PI) var angle_diff = pixel_angle - forward_angle @@ -1758,7 +1786,7 @@ func _create_cone_light_texture(): # Fade based on distance (from center) - keep distance falloff # Hard edge for angle (pixely) - no smoothstep on angle var distance_factor = 1.0 - smoothstep(0.0, 1.0, normalized_distance) - var alpha = distance_factor # Hard edge on angle, smooth fade on distance + var alpha = distance_factor # Hard edge on angle, smooth fade on distance var color = Color(1.0, 1.0, 1.0, alpha) image.set_pixel(x, y, color) else: @@ -1906,6 +1934,13 @@ func _physics_process(delta): if is_airborne: _update_z_physics(delta) + # Mana regeneration (slowly regain mana over time) + if character_stats and is_multiplayer_authority(): + # Regenerate 2 mana per second (slow regeneration) + const MANA_REGEN_RATE = 2.0 # mana per second + if character_stats.mp < character_stats.maxmp: + character_stats.restore_mana(MANA_REGEN_RATE * delta) + # Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse if is_charging_spell: var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time @@ -1986,7 +2021,7 @@ func _physics_process(delta): if is_charging_bow: var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time # Smooth curve: charge from 0.2s to 1.0s - var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s # Update tint pulse timer when fully charged if charge_progress >= 1.0: @@ -2016,13 +2051,15 @@ func _physics_process(delta): if character_stats: var old_hp = character_stats.hp character_stats.modify_health(-burn_debuff_damage_per_second) - if character_stats.hp <= 0: + # Check if dead (use epsilon to handle floating point precision) + if character_stats.hp <= 0.001: + character_stats.hp = 0.0 # Ensure exactly 0 character_stats.no_health.emit() character_stats.character_changed.emit(character_stats) var actual_damage = old_hp - character_stats.hp print(name, " takes ", actual_damage, " burn damage! Health: ", character_stats.hp, "/", character_stats.maxhp) # Show damage number for burn damage - _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number + _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number # Sync burn damage visual to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_damage", [actual_damage, global_position]) @@ -2033,7 +2070,7 @@ func _physics_process(delta): var sprite = burn_debuff_visual as Sprite2D var anim_timer = sprite.get_meta("burn_animation_timer", 0.0) anim_timer += delta - if anim_timer >= 0.1: # ~10 FPS + if anim_timer >= 0.1: # ~10 FPS anim_timer = 0.0 var frame = sprite.get_meta("burn_animation_frame", 0) frame = (frame + 1) % 16 @@ -2058,7 +2095,7 @@ func _physics_process(delta): # Exception: entrance walk-out - velocity is driven by game_world for cut-scene velocity = Vector2.ZERO # Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up) - if not spawn_landing and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": + if not spawn_landing and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH": if is_lifting: _set_animation("IDLE_HOLD") elif is_pushing: @@ -2088,17 +2125,17 @@ func _physics_process(delta): # First phase: Apply knockback toward hand if enemy_hand_grab_knockback_time < ENEMY_HAND_GRAB_KNOCKBACK_DURATION: # Still in knockback phase - let velocity carry player - velocity = velocity.lerp(Vector2.ZERO, delta * 4.0) # Slow down gradually + velocity = velocity.lerp(Vector2.ZERO, delta * 4.0) # Slow down gradually else: # Second phase: Move player toward hand position (slightly above it) var hand_pos = grabbed_by_enemy_hand.global_position - var target_pos = hand_pos + Vector2(0, -12) # Slightly above the hand + var target_pos = hand_pos + Vector2(0, -12) # Slightly above the hand # Smoothly move player to hand position var distance_to_target = global_position.distance_to(target_pos) - if distance_to_target > 2.0: # If not close enough, move toward it + if distance_to_target > 2.0: # If not close enough, move toward it var direction_to_hand = (target_pos - global_position).normalized() - velocity = direction_to_hand * 200.0 # Move speed toward hand + velocity = direction_to_hand * 200.0 # Move speed toward hand else: # Close enough - snap to position and stop global_position = target_pos @@ -2215,10 +2252,10 @@ func _handle_input(): ) input_vector.y = max( - Input.get_action_strength("move_down"), + Input.get_action_strength("move_down"), Input.get_action_strength("ui_down") ) - max( - Input.get_action_strength("move_up"), + Input.get_action_strength("move_up"), Input.get_action_strength("ui_up") ) else: @@ -2313,7 +2350,7 @@ func _handle_input(): var movement_direction = input_vector.normalized() var push_direction = push_axis.normalized() var dot_product = movement_direction.dot(push_direction) - if dot_product < -0.1: # Moving opposite to push direction = pulling + if dot_product < -0.1: # Moving opposite to push direction = pulling is_pulling = true # Prevent movement during disarming (unless cancelled or finished) @@ -2381,7 +2418,7 @@ func _handle_input(): _set_animation("RUN_PULL") else: _set_animation("RUN_PUSH") - elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": + elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("RUN") else: # Idle animations @@ -2412,7 +2449,7 @@ func _handle_input(): current_direction = new_direction _update_cone_light_rotation() else: - if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": + if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("IDLE") # Handle drag sound for interactable objects @@ -2447,9 +2484,9 @@ func _handle_input(): elif is_charging_bow: speed_multiplier = 0.5 elif is_charging_spell: - speed_multiplier = 0.2 # 20% speed (80% reduction) + speed_multiplier = 0.5 # 50% speed (50% reduction) elif is_shielding: - speed_multiplier = 0.6 # 60% speed when blocking with shield + speed_multiplier = 0.6 # 60% speed when blocking with shield var base_speed = move_speed * speed_multiplier var current_speed = base_speed @@ -2640,6 +2677,24 @@ func _handle_interactions(): break if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object: + # Check if player has enough mana before starting to charge + var has_enough_mana = false + if character_stats: + if is_fire: + has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost + elif is_frost: + has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost + else: + has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost + + if not has_enough_mana: + # Not enough mana - show message to local player only + if is_local_player: + _show_not_enough_mana_text() + print(name, " cannot start charging spell - not enough mana") + just_grabbed_this_frame = false + return + is_charging_spell = true current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire") spell_charge_start_time = Time.get_ticks_msec() / 1000.0 @@ -2681,12 +2736,40 @@ func _handle_interactions(): has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) if has_valid_target and is_fully_charged: - if is_fire: - _cast_flame_spell(target_pos) - elif is_frost: - _cast_frostspike_spell(target_pos) + # Check if player has enough mana before casting + var has_enough_mana = false + if character_stats: + if is_fire: + has_enough_mana = character_stats.mp >= 15.0 # Flame spell cost + elif is_frost: + has_enough_mana = character_stats.mp >= 15.0 # Frostspike spell cost + else: + has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost + + if has_enough_mana: + if is_fire: + _cast_flame_spell(target_pos) + elif is_frost: + _cast_frostspike_spell(target_pos) + else: + _cast_heal_spell(heal_target) else: - _cast_heal_spell(heal_target) + # Not enough mana - cancel spell + print(name, " cannot cast spell - not enough mana") + is_charging_spell = false + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + _set_animation("IDLE") + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + return _set_animation("FINISH_SPELL") movement_lock_timer = SPELL_CAST_LOCK_DURATION is_charging_spell = false @@ -2770,8 +2853,8 @@ func _handle_interactions(): # Dwarf: Create interactable bomb object that can be lifted/thrown _create_bomb_object() # Skip the normal grab handling below - bomb is already lifted - just_grabbed_this_frame = true # Set to true to prevent immediate release - grab_start_time = Time.get_ticks_msec() / 1000.0 # Record grab time + just_grabbed_this_frame = true # Set to true to prevent immediate release + grab_start_time = Time.get_ticks_msec() / 1000.0 # Record grab time return else: # Human/Elf: Throw bomb or drop next to player @@ -2853,8 +2936,16 @@ func _handle_interactions(): # 2. Button is still down (shouldn't happen, but safety check) # 3. grab_just_pressed is also true (same frame tap) if grab_just_released: - is_reviving = false - revive_charge = 0.0 + # Stop reviving if was reviving + if is_reviving: + is_reviving = false + revive_charge = 0.0 + # Sync revive end to all clients + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _sync_revive_end.rpc() + else: + is_reviving = false + revive_charge = 0.0 if grab_just_released and held_object: # For bombs that are already lifted, skip the "just grabbed" logic @@ -2920,19 +3011,35 @@ func _handle_interactions(): var holding_dead_player = _is_player(held_object) and "is_dead" in held_object and held_object.is_dead var reviver_hp = character_stats.hp if character_stats else 1.0 if holding_dead_player and reviver_hp > 1.0: - is_reviving = true + # Start reviving if not already + if not is_reviving: + is_reviving = true + # Sync revive start to all clients + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _sync_revive_start.rpc() revive_charge += get_process_delta_time() if revive_charge >= REVIVE_DURATION: _do_revive(held_object) _place_down_object() is_reviving = false revive_charge = 0.0 + # Sync revive end to all clients + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _sync_revive_end.rpc() else: _update_lifted_object() else: if holding_dead_player: - is_reviving = false - revive_charge = 0.0 + # Stop reviving if was reviving + if is_reviving: + is_reviving = false + revive_charge = 0.0 + # Sync revive end to all clients + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _sync_revive_end.rpc() + else: + is_reviving = false + revive_charge = 0.0 _update_lifted_object() # Clear the "released while lifting" flag if button is held again if grab_released_while_lifting: @@ -3012,8 +3119,8 @@ func _handle_interactions(): # Smooth curve: charge from 0.2s to 1.0s # Speed scales from 50% to 100% (160 to 320 speed) - var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s - bow_charge_percentage = 0.5 + (charge_progress * 0.5) # 0.5 to 1.0 + var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + bow_charge_percentage = 0.5 + (charge_progress * 0.5) # 0.5 to 1.0 # Release bow and shoot is_charging_bow = false @@ -3389,7 +3496,7 @@ func _stop_pushing(): # Stop drag sound when releasing object if held_object and held_object.has_method("stop_drag_sound"): held_object.stop_drag_sound() - was_dragging_last_frame = false # Reset drag state + was_dragging_last_frame = false # Reset drag state # Store reference and CURRENT position - don't change it! var released_obj = held_object @@ -3406,12 +3513,12 @@ func _stop_pushing(): released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) - released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! elif _is_player(released_obj): # Players: back on layer 1 released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) - released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"): released_obj.set_being_held(false) @@ -3436,7 +3543,7 @@ func _stop_pushing(): func _get_throw_force() -> float: # Calculate throw force based on player's STR stat # Base: 80, +3 per STR point - var str_stat = 10.0 # Default STR + var str_stat = 10.0 # Default STR if character_stats: str_stat = character_stats.baseStats.str + character_stats.get_pass("str") return base_throw_force + (str_stat * 3.0) @@ -3659,7 +3766,7 @@ func _force_throw_held_object(direction: Vector2): thrown_obj.set_collision_layer_value(2, true) thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(2, true) - thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! elif _is_player(thrown_obj): # Player: set position and physics first thrown_obj.global_position = throw_start_pos @@ -3763,7 +3870,7 @@ func _place_down_object(): bomb.name = "PlacedBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = place_pos - bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready + bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): @@ -3792,7 +3899,7 @@ func _place_down_object(): placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) - placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! # Stop movement and reset all state if "throw_velocity" in placed_obj: @@ -3813,7 +3920,7 @@ func _place_down_object(): # Player: back on layer 1 placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) - placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! placed_obj.global_position = place_pos placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): @@ -3843,17 +3950,25 @@ func _perform_attack(): var is_bow = false var is_staff = false + var is_axe = false + var is_unarmed = (equipped_weapon == null) if equipped_weapon: if equipped_weapon.weapon_type == Item.WeaponType.BOW: is_bow = true elif equipped_weapon.weapon_type == Item.WeaponType.STAFF: is_staff = true + elif equipped_weapon.weapon_type == Item.WeaponType.AXE: + is_axe = true - # Play attack animation based on weapon + # Play attack animation based on weapon (PUNCH when no mainhand) if is_bow: _set_animation("BOW") elif is_staff: _set_animation("STAFF") + elif is_axe: + _set_animation("AXE") + elif is_unarmed: + _set_animation("PUNCH") else: _set_animation("SWORD") @@ -3868,7 +3983,7 @@ func _perform_attack(): await get_tree().create_timer(0.15).timeout # Calculate damage from character_stats with randomization - var base_damage = 20.0 # Default damage + var base_damage = 20.0 # Default damage if character_stats: base_damage = character_stats.damage @@ -3880,10 +3995,10 @@ func _perform_attack(): # Critical strike chance (based on LCK stat) var crit_chance = 0.0 if character_stats: - crit_chance = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 0.01 # 1% per LCK point + crit_chance = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 0.01 # 1% per LCK point var is_crit = randf() < crit_chance if is_crit: - final_damage *= 2.0 # Critical strikes deal 2x damage + final_damage *= 2.0 # Critical strikes deal 2x damage print(name, " CRITICAL STRIKE! (LCK: ", character_stats.baseStats.lck + character_stats.get_pass("lck"), ")") # Round to 1 decimal place @@ -3891,7 +4006,7 @@ func _perform_attack(): # Track what we spawned so we only sync when we actually shot a projectile var spawned_projectile_type: String = "" - var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer) + var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer) # Handle bow attacks - require arrows in off-hand if is_bow: @@ -3961,8 +4076,31 @@ func _perform_attack(): var spawn_offset = attack_direction * 6.0 projectile.global_position = global_position + spawn_offset print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") + elif is_axe: + # Axe swing - stays on player, plays directional animation + if attack_axe_swing_scene and equipped_weapon: + spawned_projectile_type = "axe" + var axe_swing = attack_axe_swing_scene.instantiate() + get_parent().add_child(axe_swing) + axe_swing.setup(attack_direction, self, -1.0, equipped_weapon) + axe_swing.global_position = global_position + print(name, " axe swing! Damage: ", final_damage) + elif is_unarmed: + # Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage) + if attack_punch_scene: + spawned_projectile_type = "punch" + var punch_damage = 2.0 + if character_stats: + var str_total = character_stats.baseStats.str + character_stats.get_pass("str") + punch_damage = 2.0 + str_total * 0.1 + punch_damage = max(1.0, round(punch_damage * 10.0) / 10.0) + var punch = attack_punch_scene.instantiate() + get_parent().add_child(punch) + punch.setup(attack_direction, self, punch_damage) + punch.global_position = global_position + attack_direction * 12.0 + print(name, " punched! Damage: ", punch_damage) else: - # Spawn sword projectile for non-bow/staff weapons + # Spawn sword projectile for non-bow/staff/axe weapons if sword_projectile_scene: spawned_projectile_type = "sword" var projectile = sword_projectile_scene.instantiate() @@ -4082,7 +4220,7 @@ func _create_bomb_object(): var bomb_obj = interactable_object_scene.instantiate() bomb_obj.name = "BombObject_" + str(Time.get_ticks_msec()) - bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front + bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front # Set multiplayer authority if multiplayer.has_multiplayer_peer(): @@ -4104,7 +4242,7 @@ func _create_bomb_object(): bomb_obj.set_collision_layer_value(2, false) bomb_obj.set_collision_mask_value(1, false) bomb_obj.set_collision_mask_value(2, false) - bomb_obj.set_collision_mask_value(7, true) # Keep wall collision + bomb_obj.set_collision_mask_value(7, true) # Keep wall collision # Notify object it's being grabbed if bomb_obj.has_method("on_grabbed"): @@ -4135,7 +4273,7 @@ func _create_bomb_object(): var obj_name = bomb_obj.name if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree(): _rpc_to_ready_peers("_sync_create_bomb_object", [obj_name, bomb_obj.global_position]) - _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting + _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting print(name, " created bomb object! Remaining bombs: ", remaining) @@ -4160,12 +4298,12 @@ func _throw_bomb_from_offhand(throw_direction: Vector2, is_moving: bool): # Moving: throw bomb in movement direction (like enemies) var throw_force_magnitude = _get_throw_force() throw_force = throw_direction * throw_force_magnitude - bomb_start_pos = global_position + throw_direction * 12.0 # Start slightly in front + bomb_start_pos = global_position + throw_direction * 12.0 # Start slightly in front else: # Not moving: drop next to player (like Dwarf placing down) # Find a valid position next to player var game_world = get_tree().get_first_node_in_group("game_world") - var drop_pos = global_position + throw_direction * 16.0 # One tile away + var drop_pos = global_position + throw_direction * 16.0 # One tile away if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(drop_pos) if found_pos != Vector2.ZERO: @@ -4190,7 +4328,7 @@ func _throw_bomb_from_offhand(throw_direction: Vector2, is_moving: bool): bomb.set_multiplayer_authority(get_multiplayer_authority()) # Setup bomb: thrown if moving (with force), placed if not moving (no force) - bomb.setup(bomb_start_pos, self, throw_force, is_moving) # is_moving = is_thrown + bomb.setup(bomb_start_pos, self, throw_force, is_moving) # is_moving = is_thrown # Sync bomb spawn to other clients if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): @@ -4230,7 +4368,7 @@ func _place_bomb(target_position: Vector2): bomb.set_multiplayer_authority(get_multiplayer_authority()) # Setup bomb without throw (placed directly) - bomb.setup(valid_target_pos, self, Vector2.ZERO, false) # false = not thrown + bomb.setup(valid_target_pos, self, Vector2.ZERO, false) # false = not thrown # Sync bomb spawn to other clients if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): @@ -4248,6 +4386,14 @@ func _cast_flame_spell(target_position: Vector2): if not is_multiplayer_authority(): return + # Check mana cost (15 mana for flame spell) + const FLAME_SPELL_MANA_COST = 15.0 + if not character_stats: + return + if not character_stats.use_mana(FLAME_SPELL_MANA_COST): + print(name, " cannot cast flame spell - not enough mana (need ", FLAME_SPELL_MANA_COST, ", have ", character_stats.mp, ")") + return + # Find valid spell target position (closest valid if target is blocked) var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position @@ -4256,14 +4402,16 @@ func _cast_flame_spell(target_position: Vector2): if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: - # No valid position found, cancel spell + # No valid position found, cancel spell and refund mana + if character_stats: + character_stats.restore_mana(FLAME_SPELL_MANA_COST) print(name, " cannot cast spell - no valid target position") return # Calculate damage from character_stats - var spell_damage = 15.0 # Base damage + var spell_damage = 15.0 # Base damage if character_stats: - spell_damage = character_stats.damage * 0.75 # 75% of normal damage + spell_damage = character_stats.damage * 0.75 # 75% of normal damage # Spawn flame spell at valid target position var flame_spell = flame_spell_scene.instantiate() @@ -4284,7 +4432,7 @@ func _cast_flame_spell(target_position: Vector2): func _sync_flame_spell(target_position: Vector2, spell_damage: float): # Client receives flame spell spawn sync if is_multiplayer_authority(): - return # Authority already spawned it + return # Authority already spawned it if not flame_spell_scene: return @@ -4301,6 +4449,15 @@ func _cast_frostspike_spell(target_position: Vector2): return if not is_multiplayer_authority(): return + + # Check mana cost (15 mana for frostspike spell) + const FROSTSPIKE_SPELL_MANA_COST = 15.0 + if not character_stats: + return + if not character_stats.use_mana(FROSTSPIKE_SPELL_MANA_COST): + print(name, " cannot cast frostspike - not enough mana (need ", FROSTSPIKE_SPELL_MANA_COST, ", have ", character_stats.mp, ")") + return + var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position if game_world and game_world.has_method("_get_valid_spell_target_position"): @@ -4308,6 +4465,9 @@ func _cast_frostspike_spell(target_position: Vector2): if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: + # No valid position found, cancel spell and refund mana + if character_stats: + character_stats.restore_mana(FROSTSPIKE_SPELL_MANA_COST) print(name, " cannot cast frostspike - no valid target position") return var spell_damage = 15.0 @@ -4336,6 +4496,12 @@ func _cast_heal_spell(target: Node): return if not character_stats: return + + # Check mana cost (20 mana for heal spell - more expensive since it's healing) + const HEAL_SPELL_MANA_COST = 20.0 + if not character_stats.use_mana(HEAL_SPELL_MANA_COST): + print(name, " cannot cast heal spell - not enough mana (need ", HEAL_SPELL_MANA_COST, ", have ", character_stats.mp, ")") + return var gw = get_tree().get_first_node_in_group("game_world") var dungeon_seed: int = 0 if gw and "dungeon_seed" in gw: @@ -4522,7 +4688,6 @@ func _get_heal_target() -> Node: func _can_cast_spell_at(target_position: Vector2) -> bool: # Check if spell can be cast at target position # Must be on floor tile and not blocked by walls - # Get game world for dungeon data var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: @@ -4557,7 +4722,7 @@ func _can_cast_spell_at(target_position: Vector2) -> bool: var query = PhysicsRayQueryParameters2D.new() query.from = global_position query.to = target_position - query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) + query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) query.exclude = [get_rid()] var result = space_state.intersect_ray(query) @@ -4591,7 +4756,7 @@ func _update_spell_charge_particles(charge_progress: float): return # Spawn particles periodically (more frequent as charge increases) - var spawn_interval = 0.15 - (charge_progress * 0.1) # 0.15s to 0.05s interval + var spawn_interval = 0.15 - (charge_progress * 0.1) # 0.15s to 0.05s interval if spell_charge_particle_timer >= spawn_interval: spell_charge_particle_timer = 0.0 @@ -4600,7 +4765,7 @@ func _update_spell_charge_particles(charge_progress: float): particle.texture = star_texture # Random position at player's feet - var feet_y = 8.0 # Player's feet position + var feet_y = 8.0 # Player's feet position var random_x = randf_range(-4.0, 4.0) particle.position = Vector2(random_x, feet_y) @@ -4612,7 +4777,7 @@ func _update_spell_charge_particles(charge_progress: float): particle.set_meta("initial_scale", base_scale) particle.set_meta("initial_y", feet_y) particle.set_meta("lifetime", 0.0) - particle.set_meta("max_lifetime", 0.5 + (charge_progress * 0.5)) # 0.5 to 1.0 seconds + particle.set_meta("max_lifetime", 0.5 + (charge_progress * 0.5)) # 0.5 to 1.0 seconds spell_charge_particles.add_child(particle) @@ -4630,7 +4795,7 @@ func _update_spell_charge_particles(charge_progress: float): # Move upward var progress = lifetime / max_lifetime - child.position.y = initial_y - (progress * 30.0) # Move up 30 pixels + child.position.y = initial_y - (progress * 30.0) # Move up 30 pixels # Scale down as it lives var scale_factor = 1.0 - progress @@ -4686,9 +4851,9 @@ func _apply_spell_charge_tint(): return var tint = spell_charge_tint if _is_healing_spell(): - tint = Color(0.25, 2.0, 0.3, 2.0) # Green pulse for healing + tint = Color(0.25, 2.0, 0.3, 2.0) # Green pulse for healing elif _is_frost_spell(): - tint = Color(0.25, 0.5, 2.0, 2.0) # Blue pulse for frost + tint = Color(0.25, 0.5, 2.0, 2.0) # Blue pulse for frost var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, @@ -4702,7 +4867,7 @@ func _apply_spell_charge_tint(): ] # Calculate pulse value (0.0 to 1.0) using sine wave - var pulse_value = (sin(spell_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + var pulse_value = (sin(spell_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 for sprite_data in sprites: var sprite = sprite_data.sprite @@ -4817,7 +4982,7 @@ func _apply_bow_charge_tint(): ] # Calculate pulse value (0.0 to 1.0) using sine wave - var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 for sprite_data in sprites: var sprite = sprite_data.sprite @@ -4941,6 +5106,28 @@ func _sync_spell_charge_end(): print(name, " (synced) ended charging spell") +@rpc("any_peer", "reliable") +func _sync_revive_start(): + # Sync revive start to other clients - show AnimationIncantation effect + if not is_multiplayer_authority(): + is_reviving = true + revive_charge = 0.0 + # Play healing_charging animation on AnimationIncantation + if has_node("AnimationIncantation") and not is_charging_spell: + $AnimationIncantation.play("healing_charging") + print(name, " (synced) started reviving") + +@rpc("any_peer", "reliable") +func _sync_revive_end(): + # Sync revive end to other clients - stop AnimationIncantation effect + if not is_multiplayer_authority(): + is_reviving = false + revive_charge = 0.0 + # Stop healing_charging animation + if has_node("AnimationIncantation") and not is_charging_spell: + _stop_spell_charge_incantation() + print(name, " (synced) stopped reviving") + func _apply_burn_debuff(): # Apply burn debuff to player var was_already_burning = burn_debuff_timer > 0.0 @@ -4948,7 +5135,7 @@ func _apply_burn_debuff(): if was_already_burning: # Already burning - refresh duration burn_debuff_timer = burn_debuff_duration - burn_damage_timer = 0.0 # Reset damage timer + burn_damage_timer = 0.0 # Reset damage timer print(name, " burn debuff refreshed") else: # Start burn debuff @@ -4961,7 +5148,7 @@ func _apply_burn_debuff(): # Sync burn debuff to other clients (always sync, even on refresh) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _sync_burn_debuff.rpc(true) # true = apply/refresh burn debuff + _sync_burn_debuff.rpc(true) # true = apply/refresh burn debuff func _create_burn_debuff_visual(): # Remove existing visual if any @@ -4975,7 +5162,7 @@ func _create_burn_debuff_visual(): add_child(burn_debuff_visual) # Position on player (centered) burn_debuff_visual.position = Vector2(0, 0) - burn_debuff_visual.z_index = 5 # Above player sprites + burn_debuff_visual.z_index = 5 # Above player sprites burn_debuff_visual.visible = true print(name, " created burn debuff visual (scene), visible: ", burn_debuff_visual.visible, ", z_index: ", burn_debuff_visual.z_index) else: @@ -4989,7 +5176,7 @@ func _create_burn_debuff_visual(): sprite.vframes = 4 sprite.frame = 0 sprite.position = Vector2(0, 0) - sprite.z_index = 5 # Above player sprites + sprite.z_index = 5 # Above player sprites sprite.set_meta("burn_animation_frame", 0) sprite.set_meta("burn_animation_timer", 0.0) add_child(sprite) @@ -5005,7 +5192,7 @@ func _remove_burn_debuff(): # Sync burn debuff removal to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _sync_burn_debuff.rpc(false) # false = remove burn debuff + _sync_burn_debuff.rpc(false) # false = remove burn debuff @rpc("any_peer", "reliable") func _sync_burn_debuff(apply: bool): @@ -5184,6 +5371,40 @@ func _rpc_to_ready_peers(method: String, args: Array = []): else: callv("rpc", [method] + args) +# Push this player's full state (equipment, inventory, race, appearance) to a single peer. +# Used when a new joiner connects so they receive the host's (and other existing players') state. +func _push_full_state_to_peer(target_peer_id: int) -> void: + if not is_multiplayer_authority() or not character_stats or not is_inside_tree(): + return + var equipment_data = {} + for slot_name in character_stats.equipment.keys(): + var item = character_stats.equipment[slot_name] + if item: + equipment_data[slot_name] = item.save() + else: + equipment_data[slot_name] = null + var inventory_data = [] + for item in character_stats.inventory: + if item: + inventory_data.append(item.save()) + var appearance_data = { + "skin": character_stats.skin, + "hairstyle": character_stats.hairstyle, + "hair_color": character_stats.hair_color.to_html(true), + "facial_hair": character_stats.facial_hair, + "facial_hair_color": character_stats.facial_hair_color.to_html(true), + "eyes": character_stats.eyes, + "eye_color": character_stats.eye_color.to_html(true), + "eye_lashes": character_stats.eye_lashes, + "eyelash_color": character_stats.eyelash_color.to_html(true), + "add_on": character_stats.add_on + } + _sync_equipment.rpc_id(target_peer_id, equipment_data) + _sync_inventory.rpc_id(target_peer_id, inventory_data) + _sync_race_and_stats.rpc_id(target_peer_id, character_stats.race, character_stats.baseStats.duplicate()) + _sync_appearance.rpc_id(target_peer_id, appearance_data) + print(name, " pushed full state (equipment, inventory, race, appearance) to peer ", target_peer_id, " inventory size: ", inventory_data.size()) + func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: """Check if a peer is still connected and has open data channels before sending RPC""" if not multiplayer.has_multiplayer_peer(): @@ -5214,7 +5435,7 @@ func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: if connection_obj != null and typeof(connection_obj) == TYPE_INT: var connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED return false # Also verify channels array to ensure channels are actually open @@ -5227,11 +5448,11 @@ func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: if channel.has_method("get_ready_state"): var ready_state = channel.get_ready_state() # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN return false elif "ready_state" in channel: var ready_state = channel.get("ready_state") - if ready_state != 1: # Not OPEN + if ready_state != 1: # Not OPEN return false # Also check matchbox_client connection state for additional verification @@ -5243,7 +5464,7 @@ func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: if pc and pc.has_method("get_connection_state"): var matchbox_conn_state = pc.get_connection_state() # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED - if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED return false return true @@ -5291,6 +5512,10 @@ func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float _set_animation("STAFF") "arrow": _set_animation("BOW") + "axe": + _set_animation("AXE") + "punch": + _set_animation("PUNCH") _: _set_animation("SWORD") @@ -5316,6 +5541,19 @@ func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float get_parent().add_child(arrow_projectile) arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage) print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)") + elif projectile_type == "axe" and attack_axe_swing_scene: + var axe_swing = attack_axe_swing_scene.instantiate() + get_parent().add_child(axe_swing) + var axe_item = character_stats.equipment.get("mainhand", null) if character_stats else null + axe_swing.setup(attack_dir, self, -1.0, axe_item) + axe_swing.global_position = global_position + print(name, " performed synced axe swing!") + elif projectile_type == "punch" and attack_punch_scene: + var punch = attack_punch_scene.instantiate() + get_parent().add_child(punch) + punch.setup(attack_dir, self, 3.0) + punch.global_position = global_position + attack_dir * 12.0 + print(name, " performed synced punch!") elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) @@ -5363,7 +5601,7 @@ func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2): if not entities_node: return if entities_node.get_node_or_null(bomb_name): - return # Already exists (e.g. duplicate RPC) + return # Already exists (e.g. duplicate RPC) var interactable_scene = load("res://scenes/interactable_object.tscn") as PackedScene if not interactable_scene: return @@ -5392,7 +5630,7 @@ func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2): bomb.name = "PlacedBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = place_pos - bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit + bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) print(name, " (synced) dropped bomb at ", place_pos) @@ -5422,7 +5660,7 @@ func _sync_place_bomb(bomb_id: String, target_pos: Vector2): bomb.name = bomb_id get_parent().add_child(bomb) bomb.global_position = target_pos - bomb.setup(target_pos, self, Vector2.ZERO, false) # false = not thrown + bomb.setup(target_pos, self, Vector2.ZERO, false) # false = not thrown print(name, " (synced) placed bomb at ", target_pos) @rpc("any_peer", "reliable") @@ -5441,7 +5679,7 @@ func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2 bomb.name = "ThrownBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = bomb_pos - bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown + bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true print(name, " (synced) threw bomb from ", bomb_pos) @@ -5467,7 +5705,7 @@ func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_n var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5475,7 +5713,7 @@ func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_n # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_") and entities_node: - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5557,7 +5795,7 @@ func _sync_initial_grab(obj_name: String, _offset: Vector2): var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5565,7 +5803,7 @@ func _sync_initial_grab(obj_name: String, _offset: Vector2): # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5598,7 +5836,7 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5606,7 +5844,7 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5669,7 +5907,7 @@ func _sync_release(obj_name: String): var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5677,7 +5915,7 @@ func _sync_release(obj_name: String): # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5691,7 +5929,7 @@ func _sync_release(obj_name: String): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) - obj.set_collision_mask_value(7, true) # Re-enable wall collision! + obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "is_frozen" in obj: obj.is_frozen = false # CRITICAL: Clear is_being_held so object can be grabbed again and switches can detect it @@ -5702,7 +5940,7 @@ func _sync_release(obj_name: String): elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) - obj.set_collision_mask_value(7, true) # Re-enable wall collision! + obj.set_collision_mask_value(7, true) # Re-enable wall collision! if obj.has_method("set_being_held"): obj.set_being_held(false) @@ -5718,7 +5956,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5726,7 +5964,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5742,7 +5980,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) - obj.set_collision_mask_value(7, true) # Re-enable wall collision! + obj.set_collision_mask_value(7, true) # Re-enable wall collision! # Reset all state if "throw_velocity" in obj: @@ -5762,7 +6000,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2): elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) - obj.set_collision_mask_value(7, true) # Re-enable wall collision! + obj.set_collision_mask_value(7, true) # Re-enable wall collision! obj.velocity = Vector2.ZERO if obj.has_method("set_being_held"): obj.set_being_held(false) @@ -5780,8 +6018,8 @@ func _sync_teleport_position(new_pos: Vector2): position_z = 0.0 velocity_z = 0.0 is_airborne = false - spawn_landing = false # Clear spawn-fall state so we're not stuck "in air" - spawn_landing_visible_shown = true # Skip fall; we're placed, so we're "shown" + spawn_landing = false # Clear spawn-fall state so we're not stuck "in air" + spawn_landing_visible_shown = true # Skip fall; we're placed, so we're "shown" # Set flag to prevent position sync from overriding teleportation this frame teleported_this_frame = true # Always show teleported player (joiner must see self when placed in room) @@ -5805,7 +6043,7 @@ func _sync_held_object_pos(obj_name: String, pos: Vector2): var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): - return # GameWorld not ready yet + return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: @@ -5813,7 +6051,7 @@ func _sync_held_object_pos(obj_name: String, pos: Vector2): # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): - var index_str = obj_name.substr(20) # Skip "InteractableObject_" + var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): @@ -5925,7 +6163,7 @@ func _force_place_down(direction: Vector2): placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) - placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "throw_velocity" in placed_obj: placed_obj.throw_velocity = Vector2.ZERO @@ -5944,7 +6182,7 @@ func _force_place_down(direction: Vector2): elif _is_player(placed_obj): placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) - placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): placed_obj.set_being_held(false) @@ -5987,7 +6225,7 @@ func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void: var direction_to_hand = (hand_pos - global_position).normalized() # Apply knockback velocity toward the hand - velocity = direction_to_hand * 200.0 # Moderate knockback speed + velocity = direction_to_hand * 200.0 # Moderate knockback speed is_knocked_back = true knockback_time = 0.0 @@ -5999,7 +6237,7 @@ func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void: @rpc("any_peer", "reliable") func rpc_released_from_enemy_hand() -> void: grabbed_by_enemy_hand = null - enemy_hand_grab_knockback_time = 0.0 # Reset knockback timer + enemy_hand_grab_knockback_time = 0.0 # Reset knockback timer func _find_node_by_name(node: Node, n: String) -> Node: if not node: @@ -6043,10 +6281,10 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool # Use deterministic RNG based on gameworld seed and player position/time var rng_seed = world_node.dungeon_seed rng_seed += int(global_position.x) * 1000 + int(global_position.y) - rng_seed += int(Time.get_ticks_msec() / 100.0) # Add time component for uniqueness + rng_seed += int(Time.get_ticks_msec() / 100.0) # Add time component for uniqueness var rng = RandomNumberGenerator.new() rng.seed = rng_seed - should_cancel = rng.randf() < 0.5 # 50% chance + should_cancel = rng.randf() < 0.5 # 50% chance else: # Fallback to regular random if no gameworld seed should_cancel = randf() < 0.5 @@ -6082,11 +6320,11 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool _was_dodged = true print(name, " DODGED the attack! (DEX: ", character_stats.baseStats.dex + character_stats.get_pass("dex"), ", dodge chance: ", dodge_chance * 100.0, "%)") # Show "DODGED" text - _show_damage_number(0.0, attacker_position, false, false, true) # is_dodged = true + _show_damage_number(0.0, attacker_position, false, false, true) # is_dodged = true # Sync dodge visual to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true - return # No damage taken, exit early + _rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true + return # No damage taken, exit early # Check for shield block (would have hit; enemy attack from blocked direction; no burn) if not is_burn_damage and is_shielding and shield_block_cooldown_timer <= 0.0: @@ -6095,16 +6333,16 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool dir_to_attacker = Vector2.RIGHT var block_dir = shield_block_direction.normalized() if shield_block_direction.length() > 0.01 else Vector2.DOWN var dot = block_dir.dot(dir_to_attacker) - if dot > 0.5: # Lenient: attacker in front (~60° cone) + if dot > 0.5: # Lenient: attacker in front (~60° cone) # Blocked: no damage, small knockback, BLOCKED notification, cooldown shield_block_cooldown_timer = shield_block_cooldown_duration var direction_from_attacker = (global_position - attacker_position).normalized() - velocity = direction_from_attacker * 90.0 # Small knockback + velocity = direction_from_attacker * 90.0 # Small knockback is_knocked_back = true knockback_time = 0.0 if has_node("SfxBlockWithShield"): $SfxBlockWithShield.play() - _show_damage_number(0.0, attacker_position, false, false, false, true) # is_blocked = true + _show_damage_number(0.0, attacker_position, false, false, false, true) # is_blocked = true if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, false, true]) print(name, " BLOCKED attack from direction ", dir_to_attacker) @@ -6122,11 +6360,13 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool var actual_damage = amount if character_stats: # Calculate damage after DEF reduction (critical hits pierce 80% of DEF) - actual_damage = character_stats.calculate_damage(amount, false, false) # false = not magical, false = not critical (enemy attacks don't crit yet) + actual_damage = character_stats.calculate_damage(amount, false, false) # false = not magical, false = not critical (enemy attacks don't crit yet) # Apply the reduced damage using take_damage (which handles health modification and signals) var _old_hp = character_stats.hp character_stats.modify_health(-actual_damage) - if character_stats.hp <= 0: + # Check if dead (use epsilon to handle floating point precision) + if character_stats.hp <= 0.001: + character_stats.hp = 0.0 # Ensure exactly 0 character_stats.no_health.emit() character_stats.character_changed.emit(character_stats) print(name, " took ", actual_damage, " damage (", amount, " base - ", character_stats.defense, " DEF = ", actual_damage, ")! Health: ", character_stats.hp, "/", character_stats.maxhp) @@ -6160,7 +6400,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool velocity = direction_from_attacker * 250.0 # Scaled down for 1x scale # Face the attacker (opposite of knockback direction) - var face_direction = -direction_from_attacker + var face_direction = - direction_from_attacker current_direction = _get_direction_from_vector(face_direction) as Direction facing_direction_vector = face_direction.normalized() @@ -6187,12 +6427,13 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool _rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position]) # Check if dead - but wait for damage animation to play first + # Use small epsilon to handle floating point precision issues (HP might be 0.0000001 instead of exactly 0.0) var health = character_stats.hp if character_stats else current_health - if health <= 0: + if health <= 0.001: # Use epsilon to catch values very close to 0 if character_stats: - character_stats.hp = 0 # Clamp to 0 + character_stats.hp = 0.0 # Clamp to exactly 0 else: - current_health = 0 # Clamp to 0 + current_health = 0.0 # Clamp to exactly 0 is_dead = true # Set flag immediately to prevent more damage # Wait a bit for damage animation and knockback to show await get_tree().create_timer(0.3).timeout @@ -6227,7 +6468,7 @@ func _die(): released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) - released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if "is_being_held" in released_obj: released_obj.is_being_held = false if "held_by_player" in released_obj: @@ -6235,7 +6476,7 @@ func _die(): elif _is_player(released_obj): released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) - released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! if released_obj.has_method("set_being_held"): released_obj.set_being_held(false) @@ -6301,7 +6542,7 @@ func _die(): # Re-enable our collision set_collision_layer_value(1, true) set_collision_mask_value(1, true) - set_collision_mask_value(7, true) # Re-enable wall collision! + set_collision_mask_value(7, true) # Re-enable wall collision! # THEN sync to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): @@ -6325,7 +6566,7 @@ func _die(): if gw and gw.has_method("_register_player_died"): gw._register_player_died(self) - var respawn_requested = [false] # ref so lambda can mutate + var respawn_requested = [false] # ref so lambda can mutate if gw and gw.has_signal("respawn_all_ready"): var on_ready = func(): respawn_requested[0] = true gw.respawn_all_ready.connect(on_ready, CONNECT_ONE_SHOT) @@ -6407,7 +6648,8 @@ func _spawn_landing_stand_up(): status_anim.play("idle") # STAND's nextAnimation -> IDLE, so we're already IDLE or about to be spawn_landing = false - if cone_light: + # Only show cone light for local player (don't show other players' cone lights) + if is_local_player and cone_light: cone_light.visible = true if point_light: point_light.visible = true @@ -6428,9 +6670,11 @@ func _respawn(): print(name, " respawning!") was_revived = false + # Get game_world reference (used multiple times in this function) + var game_world = get_tree().get_first_node_in_group("game_world") + # Hide GAME OVER screen and fade in game graphics when player respawns (only on authority) if is_multiplayer_authority(): - var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_hide_game_over"): game_world._hide_game_over() @@ -6440,7 +6684,7 @@ func _respawn(): # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) - set_collision_mask_value(7, true) # Re-enable wall collision! + set_collision_mask_value(7, true) # Re-enable wall collision! # Reset health and state if character_stats: @@ -6469,7 +6713,6 @@ func _respawn(): # Get respawn position - use spawn room (start room) for respawning var new_respawn_pos = respawn_point - var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: push_error(name, " respawn: Could not find game_world!") @@ -6575,7 +6818,7 @@ func _force_holder_to_drop_local(holder_name: String): # Re-enable collision on dropped player set_collision_layer_value(1, true) set_collision_mask_value(1, true) - set_collision_mask_value(7, true) # Re-enable wall collision! + set_collision_mask_value(7, true) # Re-enable wall collision! else: print(" ✗ held_object doesn't match self") else: @@ -6621,6 +6864,10 @@ func _revive_from_player(hp_amount: int): _set_animation("IDLE") # Same healing effect as Tome of Healing (green frames, pulse, +X HP) _spawn_heal_effect_and_text(self, hp_amount, false, false) + # CRITICAL: Unregister from dead_players dictionary so game knows we're alive + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_unregister_player_died"): + game_world._unregister_player_died(self) # Clear concussion on all clients (authority already did above; broadcast for others) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) @@ -6661,6 +6908,10 @@ func _revive_from_heal(hp_amount: int): status_anim.play("idle") _set_animation("IDLE") _spawn_heal_effect_and_text(self, hp_amount, false, false) + # CRITICAL: Unregister from dead_players dictionary so game knows we're alive + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_unregister_player_died"): + game_world._unregister_player_died(self) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) @@ -6693,7 +6944,7 @@ func _sync_respawn(spawn_pos: Vector2): # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) - set_collision_mask_value(7, true) # Re-enable wall collision! + set_collision_mask_value(7, true) # Re-enable wall collision! # Reset health and state if character_stats: @@ -6745,31 +6996,39 @@ func add_coins(amount: int): var the_peer_id = get_multiplayer_authority() # Only sync if this is a client player (not server's own player) if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id(): - print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin) - _sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin) + print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin, " xp=", character_stats.xp) + _sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin, character_stats.xp) else: coins += amount print(name, " picked up ", amount, " coin(s)! Total coins: ", coins) @rpc("any_peer", "reliable") -func _sync_stats_update(kills_count: int, coins_count: int): - # Client receives stats update from server (for kills and coins) +func _sync_stats_update(kills_count: int, coins_count: int, xp_amount: float = -1.0): + # Client receives stats update from server (for kills, coins, and XP) # Update local stats to match server # Only process on client (not on server where the update originated) if multiplayer.is_server(): - return # Server ignores this (it's the sender) + return # Server ignores this (it's the sender) if character_stats: character_stats.kills = kills_count character_stats.coin = coins_count - print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count) + if xp_amount >= 0.0: # Only update XP if provided (backwards compatible) + # Calculate the difference and add it (to trigger level up if needed) + var xp_diff = xp_amount - character_stats.xp + if xp_diff > 0.0: + character_stats.add_xp(xp_diff) + else: + character_stats.xp = xp_amount + var xp_display = str(xp_amount) if xp_amount >= 0.0 else "unchanged" + print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count, " xp=", xp_display) @rpc("any_peer", "reliable") func _sync_race_and_stats(race: String, base_stats: Dictionary): # Client receives race and base stats from authority player # Accept initial sync (when race is empty), but reject changes if we're authority - print(name, " _sync_race_and_stats received: race=", race, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null") + print("Player ", name, " RECEIVED _sync_race_and_stats: race='", race, "' (peer_id=", peer_id, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null", ")") # CRITICAL: If we're the authority for this player, we should NOT process race syncs # The authority player manages its own appearance and only syncs to others @@ -6783,6 +7042,7 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): if character_stats: character_stats.race = race character_stats.baseStats = base_stats + print("Player ", name, " APPLIED _sync_race_and_stats: this node now has race='", race, "' (server/other peer's view of this player)") # For remote players, we don't re-initialize appearance here # Instead, we wait for _sync_appearance RPC which contains the full appearance data @@ -6819,20 +7079,29 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): "Dwarf": character_stats.setEars(0) - # Give Dwarf starting bombs to remote players ONLY when offhand is null (initial sync) + # Give Dwarf starting bombs + debug weapons to remote players ONLY when offhand is null (initial sync) # Never overwrite existing equipment (e.g. shield, tome) - preserves loadout across level transitions if not is_multiplayer_authority(): if character_stats.equipment["offhand"] == null: var starting_bomb = ItemDatabase.create_item("bomb") if starting_bomb: - starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start + starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start character_stats.equipment["offhand"] = starting_bomb - _apply_appearance_to_sprites() - print("Dwarf player ", name, " (remote) received 5 bombs via race sync") + var debug_axe = ItemDatabase.create_item("axe") + if debug_axe: + character_stats.add_item(debug_axe) + var debug_dagger = ItemDatabase.create_item("knife") + if debug_dagger: + character_stats.add_item(debug_dagger) + var debug_sword = ItemDatabase.create_item("short_sword") + if debug_sword: + character_stats.add_item(debug_sword) + _apply_appearance_to_sprites() + print("Dwarf player ", name, " (remote) received 5 bombs and debug axe/dagger/sword via race sync") "Human": character_stats.setEars(0) - # Give Human (Wizard) starting tome and hat to remote players ONLY when slots are null (initial sync) + # Give Human (Wizard) starting tomes and hat to remote players ONLY when slots are null (initial sync) # Never overwrite existing equipment - preserves loadout across level transitions if not is_multiplayer_authority(): var offhand_empty = character_stats.equipment["offhand"] == null @@ -6841,11 +7110,17 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: character_stats.equipment["offhand"] = starting_tome + var tome_healing = ItemDatabase.create_item("tome_of_healing") + if tome_healing: + character_stats.add_item(tome_healing) + var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike") + if tome_frostspike: + character_stats.add_item(tome_frostspike) var starting_hat = ItemDatabase.create_item("hat") if starting_hat: character_stats.equipment["headgear"] = starting_hat _apply_appearance_to_sprites() - print("Human player ", name, " (remote) received Tome of Flames and Hat via race sync") + print("Human player ", name, " (remote) received Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat via race sync") _: character_stats.setEars(0) @@ -7013,7 +7288,6 @@ func _sync_keys(new_key_count: int): func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false): # Show damage number (red, using dmg_numbers.png font) above player # Show even if amount is 0 for MISS/DODGED/BLOCKED - var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return @@ -7031,10 +7305,10 @@ func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = damage_label.color = Color.GRAY elif is_blocked: damage_label.label = "BLOCKED" - damage_label.color = Color(0.4, 0.65, 1.0) # Light blue + damage_label.color = Color(0.4, 0.65, 1.0) # Light blue else: damage_label.label = str(int(amount)) - damage_label.color = Color(1.0, 0.6, 0.2) if is_crit else Color(1.0, 0.35, 0.35) # Bright orange / bright red + damage_label.color = Color(1.0, 0.6, 0.2) if is_crit else Color(1.0, 0.35, 0.35) # Bright orange / bright red damage_label.z_index = 5 # Calculate direction from attacker (slight upward variation) @@ -7049,7 +7323,7 @@ func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = var entities_node = game_world.get_node_or_null("Entities") if entities_node: entities_node.add_child(damage_label) - damage_label.global_position = global_position + Vector2(0, -16) # Above player head + damage_label.global_position = global_position + Vector2(0, -16) # Above player head else: get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) @@ -7077,6 +7351,24 @@ func _show_revive_cost_number(amount: int): get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) +func _show_not_enough_mana_text(): + """Show 'NOT ENOUGH MANA' in damage_number font above player (local player only).""" + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene: + return + var lbl = damage_number_scene.instantiate() + if not lbl: + return + lbl.label = "NOT ENOUGH MANA" + lbl.color = Color(1.0, 0.5, 0.2) # Orange/red color + lbl.z_index = 5 + lbl.direction = Vector2(0, -1) + var game_world = get_tree().get_first_node_in_group("game_world") + var parent = game_world.get_node_or_null("Entities") if game_world else get_tree().current_scene + if parent: + parent.add_child(lbl) + lbl.global_position = global_position + Vector2(0, -20) + func show_floating_status(text: String, col: Color = Color.WHITE) -> void: """Show a damage-number-style floating text above player (e.g. 'Encumbered!').""" var damage_number_scene = preload("res://scenes/damage_number.tscn") @@ -7123,7 +7415,7 @@ func _on_level_up_stats(stats_increased: Array): "dex": Color.GREEN, "int": Color.BLUE, "end": Color.WHITE, - "wis": Color(0.5, 0.0, 0.5), # Purple + "wis": Color(0.5, 0.0, 0.5), # Purple "lck": Color.YELLOW } @@ -7139,14 +7431,14 @@ func _on_level_up_stats(stats_increased: Array): if not entities_node: entities_node = get_tree().current_scene - var base_y_offset = -32.0 # Start above player head - var y_spacing = 12.0 # Space between each text + var base_y_offset = -32.0 # Start above player head + var y_spacing = 12.0 # Space between each text # Show "LEVEL UP +1!" prominently (gold, larger, longer on screen) var level_up_text = damage_number_scene.instantiate() if level_up_text: level_up_text.label = "LEVEL UP +1!" - level_up_text.color = Color(1.0, 0.88, 0.2) # Gold + level_up_text.color = Color(1.0, 0.88, 0.2) # Gold level_up_text.direction = Vector2(0, -1) level_up_text.rise_distance = 48.0 level_up_text.fade_delay = 1.4 @@ -7164,7 +7456,7 @@ func _on_level_up_stats(stats_increased: Array): var display_name = stat_display_names.get(stat_name, stat_name.to_upper()) stat_text.label = "+1 " + display_name stat_text.color = stat_colors.get(stat_name, Color.WHITE) - stat_text.direction = Vector2(randf_range(-0.2, 0.2), -1.0).normalized() # Slight random spread + stat_text.direction = Vector2(randf_range(-0.2, 0.2), -1.0).normalized() # Slight random spread entities_node.add_child(stat_text) stat_text.global_position = global_position + Vector2(0, base_y_offset) base_y_offset -= y_spacing @@ -7222,7 +7514,7 @@ func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = fa velocity = direction_from_attacker * 250.0 # Face the attacker - var face_direction = -direction_from_attacker + var face_direction = - direction_from_attacker current_direction = _get_direction_from_vector(face_direction) as Direction facing_direction_vector = face_direction.normalized() @@ -7279,7 +7571,7 @@ func _show_alert_indicator(): func _on_trap_detected(): # Called when player detects a trap if not is_multiplayer_authority(): - return # Only authority triggers + return # Only authority triggers # Show exclamation mark _show_alert_indicator() @@ -7295,7 +7587,14 @@ func _on_trap_detected(): func _on_exit_found(): # Called when player finds exit stairs if not is_multiplayer_authority(): - return # Only authority triggers + return # Only authority triggers + + # Only show notification once per level + if has_seen_exit_this_level: + return + + # Mark as seen for this level + has_seen_exit_this_level = true # Show exclamation mark _show_alert_indicator() @@ -7312,7 +7611,7 @@ func _on_exit_found(): func _sync_trap_detected_alert(): # Sync trap detection alert to all clients if is_multiplayer_authority(): - return # Authority already handled it locally + return # Authority already handled it locally # Show exclamation mark _show_alert_indicator() @@ -7325,7 +7624,7 @@ func _sync_trap_detected_alert(): func _sync_exit_found_alert(): # Sync exit found alert to all clients if is_multiplayer_authority(): - return # Authority already handled it locally + return # Authority already handled it locally # Show exclamation mark _show_alert_indicator() diff --git a/src/scripts/player_manager.gd b/src/scripts/player_manager.gd index c0ac4ad..c99d17d 100644 --- a/src/scripts/player_manager.gd +++ b/src/scripts/player_manager.gd @@ -83,6 +83,12 @@ func spawn_player(peer_id: int, local_index: int): player.position = spawn_pos + # Set multiplayer authority BEFORE add_child so _ready() sees correct authority. + # Otherwise joiner's player runs _ready() with default authority (e.g. server) and skips _setup_player_appearance(), + # so joiner Wizard never gets tome_of_healing / tome_of_frostspike. + if multiplayer.has_multiplayer_peer(): + player.set_multiplayer_authority(peer_id) + # Add to YSort node for automatic Y-sorting var ysort = get_parent().get_node_or_null("Entities") if ysort: @@ -91,10 +97,6 @@ func spawn_player(peer_id: int, local_index: int): # Fallback to parent if YSort doesn't exist get_parent().add_child(player) - # Set multiplayer authority AFTER adding to scene - if multiplayer.has_multiplayer_peer(): - player.set_multiplayer_authority(peer_id) - players[unique_id] = player func despawn_players_for_peer(peer_id: int): diff --git a/src/scripts/room_trigger.gd b/src/scripts/room_trigger.gd index d7bf6c3..86b8225 100644 --- a/src/scripts/room_trigger.gd +++ b/src/scripts/room_trigger.gd @@ -49,6 +49,16 @@ func _on_player_entered_room(player: Node): LogManager.log("RoomTrigger: This trigger is for room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("RoomTrigger: Found " + str(doors_in_room.size()) + " doors in this room", LogManager.CATEGORY_DUNGEON) + # Check if this is the exit room (has EXIT modifier) - trigger exit found notification + if room.has("modifiers") and room.modifiers is Array: + for modifier in room.modifiers: + if modifier is Dictionary and modifier.has("type") and modifier.type == "EXIT": + # This is the exit room - notify player to show alert and play sound + if player and is_instance_valid(player) and player.has_method("_on_exit_found"): + player._on_exit_found() + LogManager.log("RoomTrigger: Exit room entered! Player " + str(player.name) + " found the exit!", LogManager.CATEGORY_DUNGEON) + break + # Mark room as entered and update debug label room_entered = true _update_debug_label() diff --git a/src/scripts/select_class.gd b/src/scripts/select_class.gd new file mode 100644 index 0000000..dd5e9cb --- /dev/null +++ b/src/scripts/select_class.gd @@ -0,0 +1,96 @@ +extends Node2D +# Race select: hover/click the race buttons to reveal and select. Keyboard (move_left/right, attack) still works. +# Dwarf preselected. Emits race_selected(race_name) when confirmed. + +signal race_selected(race_name: String) + +const RACES: Array[String] = ["Dwarf", "Elf", "Human"] # Wizard = Human + +var selected_index: int = 0 # Dwarf preselected +var hovered_index: int = -1 # -1 = no hover +var _selection_confirmed: bool = false # One-shot: only first click counts (avoids overlapping buttons overwriting) + +@onready var dwarf_node: Node2D = $Dwarf +@onready var elf_node: Node2D = $Elf +@onready var wizard_node: Node2D = $Wizard +@onready var button_dwarf: Button = $Control/ButtonDwarf +@onready var button_elf: Button = $Control/ButtonElf +@onready var button_wizard: Button = $Control/ButtonWizard + +func _ready() -> void: + var vp = get_viewport() + if vp: + position = vp.get_visible_rect().size / 2.0 + _update_display() + _connect_buttons() + +func _connect_buttons() -> void: + var buttons := [button_dwarf, button_elf, button_wizard] + for i in buttons.size(): + var btn: Button = buttons[i] + if not btn: + continue + # Hover: show this race + btn.mouse_entered.connect(_on_race_button_mouse_entered.bind(i)) + btn.mouse_exited.connect(_on_race_button_mouse_exited) + # Click: select this race and confirm + btn.pressed.connect(_on_race_button_pressed.bind(i)) + +func _on_race_button_mouse_entered(race_index: int) -> void: + hovered_index = race_index + _update_display() + +func _on_race_button_mouse_exited() -> void: + hovered_index = -1 + _update_display() + +func _on_race_button_pressed(race_index: int) -> void: + # Use the clicked button's index directly (don't rely on selected_index - another button may have fired too) + selected_index = race_index + _confirm_selection_with_index(race_index) + +func _input(event: InputEvent) -> void: + var vp = get_viewport() + if not vp: + return + if event.is_action_pressed("move_left"): + selected_index = (selected_index - 1 + RACES.size()) % RACES.size() + hovered_index = -1 + _update_display() + vp.set_input_as_handled() + if event.is_action_pressed("move_right"): + selected_index = (selected_index + 1) % RACES.size() + hovered_index = -1 + _update_display() + vp.set_input_as_handled() + if event.is_action_pressed("attack"): + # Attack can be mouse1 or keyboard: use hovered race if mouse is over a button, else keyboard selection + var index_to_confirm := hovered_index if hovered_index >= 0 else selected_index + _confirm_selection_with_index(index_to_confirm) + vp.set_input_as_handled() + +func _update_display() -> void: + var show_index: int = hovered_index if hovered_index >= 0 else selected_index + if dwarf_node: + dwarf_node.visible = (show_index == 0) + if elf_node: + elf_node.visible = (show_index == 1) + if wizard_node: + wizard_node.visible = (show_index == 2) + +func _confirm_selection_with_index(clicked_index: int) -> void: + # Only process first confirmation (overlapping buttons can fire multiple signals; first click wins) + if _selection_confirmed: + return + _selection_confirmed = true + # Use the index from the button that was actually clicked (not selected_index which could be overwritten) + if clicked_index < 0 or clicked_index >= RACES.size(): + clicked_index = selected_index + var race := RACES[clicked_index] + var gs = get_node_or_null("/root/GameState") + if gs and "selected_race" in gs: + gs.selected_race = race + print("SelectClass: joiner clicked index ", clicked_index, " -> race '", race, "', GameState.selected_race = '", gs.selected_race, "'") + else: + print("SelectClass: joiner clicked index ", clicked_index, " -> race '", race, "' but GameState not found") + race_selected.emit(race) diff --git a/src/scripts/select_class.gd.uid b/src/scripts/select_class.gd.uid new file mode 100644 index 0000000..293973f --- /dev/null +++ b/src/scripts/select_class.gd.uid @@ -0,0 +1 @@ +uid://cwbrfwrwt3krh diff --git a/src/scripts/staff_projectile.gd b/src/scripts/staff_projectile.gd index 1eeb129..58c1237 100644 --- a/src/scripts/staff_projectile.gd +++ b/src/scripts/staff_projectile.gd @@ -127,22 +127,12 @@ func _on_body_entered(body): # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) hit_targets[body] = true - # Deal damage to players - call RPC to let victim apply damage on their client + # Ignore other players - projectile passes through (no friendly fire) if body.is_in_group("player") and body.has_method("rpc_take_damage"): - $SfxImpact.play() - var attacker_pos = player_owner.global_position if player_owner else global_position - var player_peer_id = body.get_multiplayer_authority() - if player_peer_id != 0: - if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): - body.rpc_take_damage(damage, attacker_pos) - else: - body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos) - else: - body.rpc_take_damage.rpc(damage, attacker_pos) - print("Staff projectile hit player: ", body.name, " for ", damage, " damage!") + return # Deal damage to enemies - only authority (creator) deals damage - elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): var attacker_pos = player_owner.global_position if player_owner else global_position var is_crit = get_meta("is_crit") if has_meta("is_crit") else false diff --git a/src/scripts/stairs.gd b/src/scripts/stairs.gd index f98860b..0a99ed5 100644 --- a/src/scripts/stairs.gd +++ b/src/scripts/stairs.gd @@ -31,10 +31,6 @@ func _on_body_entered(body: Node2D): if body and body.is_in_group("player") and not body.is_dead: print("Stairs: Player entered stairs! Player: ", body.name) - # Notify the player to show alert and play sound - if body and is_instance_valid(body) and body.has_method("_on_exit_found"): - body._on_exit_found() - # Play stairs sound effect if sfx_stairs and sfx_stairs.stream: sfx_stairs.play() diff --git a/src/scripts/sword_projectile.gd b/src/scripts/sword_projectile.gd index 806b6a2..c3b44ca 100644 --- a/src/scripts/sword_projectile.gd +++ b/src/scripts/sword_projectile.gd @@ -28,7 +28,7 @@ func _ready(): func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0): travel_direction = direction.normalized() player_owner = owner_player - damage = damage_value # Set damage from player + damage = damage_value # Set damage from player current_speed = initial_speed # Rotate sprite to face travel direction @@ -76,39 +76,37 @@ func _on_body_entered(body): # Server creates the projectile first, then clients create it via _sync_attack # Without this check, both server and client projectiles would deal damage if player_owner and not player_owner.is_multiplayer_authority(): - return # Only the authority (creator) of the projectile can deal damage + return # Only the authority (creator) of the projectile can deal damage # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) hit_targets[body] = true - # Deal damage to players - call RPC to let victim apply damage on their client - # Pass the attacker's position (not projectile position) for accurate direction + # Friendly fire: only skip when owner is also a player. Enemies can hit players. if body.is_in_group("player") and body.has_method("rpc_take_damage"): - $SfxImpact.play() + if player_owner and player_owner.is_in_group("player"): + return # Owner is player → pass through, no damage + # Owner is enemy → deal damage to player (same pattern as enemy_base) var attacker_pos = player_owner.global_position if player_owner else global_position var player_peer_id = body.get_multiplayer_authority() if player_peer_id != 0: - # If target peer is the same as server (us), call directly - # rpc_id() might not execute locally when called to same peer - if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): - # Call directly on the same peer - body.rpc_take_damage(damage, attacker_pos) + if multiplayer.get_unique_id() == player_peer_id: + body.rpc_take_damage(damage, attacker_pos, false, false) else: - # Send RPC to remote peer - body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos) + body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos, false, false) else: - # Fallback: broadcast if we can't get peer_id - body.rpc_take_damage.rpc(damage, attacker_pos) - print("Sword projectile hit player: ", body.name, " for ", damage, " damage!") - + body.rpc_take_damage.rpc(damage, attacker_pos, false, false) + if has_node("SfxImpact"): + $SfxImpact.play() + return + # Deal damage to enemies - only authority (creator) deals damage - elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + if body.is_in_group("enemy") and body.has_method("rpc_take_damage"): var attacker_pos = player_owner.global_position if player_owner else global_position var is_crit = get_meta("is_crit") if has_meta("is_crit") else false # Check hit chance (based on player's DEX stat) var hit_roll = randf() - var hit_chance = 0.95 # Base hit chance + var hit_chance = 0.95 # Base hit chance if player_owner and player_owner.character_stats: hit_chance = player_owner.character_stats.hit_chance var is_miss = hit_roll >= hit_chance @@ -118,8 +116,8 @@ func _on_body_entered(body): print("Player MISSED enemy: ", body.name, "! (hit chance: ", hit_chance * 100.0, "%)") # Show MISS text on the enemy if body.has_method("_show_damage_number"): - body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true - return # Don't deal damage, don't play impact sound, don't cause knockback + body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true + return # Don't deal damage, don't play impact sound, don't cause knockback # Hit successful - play impact sound and deal damage $SfxImpact.play() @@ -147,6 +145,16 @@ func _on_body_entered(body): else: body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) + # Slash hit effect (sync so all clients see it) + var hit_pos = body.global_position + if game_world and game_world.has_method("_sync_slash_hit_effect"): + if multiplayer.is_server(): + game_world._sync_slash_hit_effect(hit_pos.x, hit_pos.y) + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_slash_hit_effect", [hit_pos.x, hit_pos.y]) + else: + game_world._request_slash_hit_effect.rpc_id(1, hit_pos.x, hit_pos.y) + # Debug print - handle null player_owner safely var owner_name: String = "none" var is_authority: bool = false diff --git a/src/scripts/sword_slash.gd b/src/scripts/sword_slash.gd index 9a9f503..130433f 100644 --- a/src/scripts/sword_slash.gd +++ b/src/scripts/sword_slash.gd @@ -3,16 +3,16 @@ extends Node2D # Sword Slash - Swings around player and deals damage @export var damage: float = 20.0 -@export var swing_speed: float = 720.0 # Degrees per second -@export var swing_radius: float = 40.0 # Distance from player center (closer swing) -@export var lifetime: float = 0.3 # How long the slash lasts +@export var swing_speed: float = 720.0 # Degrees per second +@export var swing_radius: float = 40.0 # Distance from player center (closer swing) +@export var lifetime: float = 0.3 # How long the slash lasts -var swing_angle: float = 0.0 # Current angle -var swing_start_angle: float = 0.0 # Starting angle -var swing_arc: float = 180.0 # Total arc to swing (180 degrees) +var swing_angle: float = 0.0 # Current angle +var swing_start_angle: float = 0.0 # Starting angle +var swing_arc: float = 180.0 # Total arc to swing (180 degrees) var elapsed_time: float = 0.0 var player_owner: Node = null -var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) +var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) @onready var sprite = $Sprite2D @onready var hit_area = $Area2D @@ -29,7 +29,7 @@ func setup(start_angle: float, owner_player: Node, arc_direction: float = 1.0): swing_start_angle = start_angle swing_angle = start_angle player_owner = owner_player - swing_arc = 180.0 * arc_direction # Positive or negative arc + swing_arc = 180.0 * arc_direction # Positive or negative arc rotation = deg_to_rad(swing_start_angle) func _physics_process(delta): @@ -64,13 +64,24 @@ func _on_body_entered(body): # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) hit_targets[body] = true - # Deal damage to players + # Friendly fire: only skip when owner is also a player. Enemies can hit players. if body.is_in_group("player") and body.has_method("take_damage"): - body.take_damage(damage, global_position) - print("Sword hit player: ", body.name, " for ", damage, " damage!") - + if player_owner and player_owner.is_in_group("player"): + return # Owner is player → pass through, no damage + # Owner is enemy → deal damage to player + var attacker_pos = player_owner.global_position if player_owner and is_instance_valid(player_owner) else global_position + var player_peer_id = body.get_multiplayer_authority() + if player_peer_id != 0: + if multiplayer.get_unique_id() == player_peer_id: + body.rpc_take_damage(damage, attacker_pos, false, false) + else: + body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos, false, false) + else: + body.rpc_take_damage.rpc(damage, attacker_pos, false, false) + return + # Deal damage to boxes or other damageable objects - elif "health" in body: + if "health" in body: # Boxes have health property body.health -= damage if body.health <= 0 and body.has_method("_break_into_pieces"): diff --git a/src/scripts/trap.gd b/src/scripts/trap.gd index b8e8e7d..c038444 100644 --- a/src/scripts/trap.gd +++ b/src/scripts/trap.gd @@ -264,6 +264,38 @@ func _complete_disarm() -> void: # Change trap visual to show it's disarmed (optional - could fade out or change color) sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) + # Grant EXP to all players for disarming trap (only on server) + # CRITICAL: Only server should grant EXP to avoid duplicates + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var trap_exp_reward = 8.0 # EXP reward for disarming a trap + var all_players = get_tree().get_nodes_in_group("player") + var valid_players = [] + for player in all_players: + if is_instance_valid(player) and player.character_stats: + valid_players.append(player) + + if valid_players.size() > 0: + # Split EXP evenly among all players + var exp_per_player = trap_exp_reward / valid_players.size() + for player in valid_players: + player.character_stats.add_xp(exp_per_player) + LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_ENEMY) + + # Sync EXP to client if this player belongs to a client + var player_peer_id = player.get_multiplayer_authority() + if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"): + var coins = player.character_stats.coin if "coin" in player.character_stats else 0 + var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0 + player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp) + + # Show floating EXP text at trap position and sync to all clients + # Show locally first + _show_exp_number(exp_per_player, global_position) + # Sync to all clients via game_world + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): + game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position) + # Sync disarm to all clients (including host when joiner disarms) if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): var game_world = get_tree().get_first_node_in_group("game_world") @@ -291,6 +323,38 @@ func _sync_trap_disarmed() -> void: if activation_area: activation_area.monitoring = false +func _show_exp_number(amount: float, exp_pos: Vector2): + # Show EXP number (green, using dmg_numbers.png font) at position + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene: + return + + var exp_label = damage_number_scene.instantiate() + if not exp_label: + return + + # Set text and color for EXP (green) + exp_label.label = "+" + str(int(amount)) + " EXP" + exp_label.color = Color(0.4, 1.0, 0.4) # Bright green + exp_label.z_index = 5 + + # Direction is straight up + exp_label.direction = Vector2(0, -1) + + # Position at the specified location + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + entities_node.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) + else: + get_tree().current_scene.add_child(exp_label) + exp_label.global_position = exp_pos + Vector2(0, -20) + func _show_floating_text(text: String, color: Color) -> void: var floating_text_scene = preload("res://scenes/floating_text.tscn") if floating_text_scene: