diff --git a/src/assets/fonts/dmg_numbers.png b/src/assets/fonts/dmg_numbers.png index d721530..765e63b 100644 Binary files a/src/assets/fonts/dmg_numbers.png and b/src/assets/fonts/dmg_numbers.png differ diff --git a/src/assets/gfx/character_select/characters.png b/src/assets/gfx/character_select/characters.png new file mode 100644 index 0000000..8602f0c Binary files /dev/null and b/src/assets/gfx/character_select/characters.png differ 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 0000000..0b71081 Binary files /dev/null and b/src/assets/gfx/character_select/characters2.png differ 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 0000000..dbcd95f Binary files /dev/null and b/src/assets/gfx/character_select/dwarf.png differ diff --git a/src/assets/gfx/character_select/dwarf.png.import b/src/assets/gfx/character_select/dwarf.png.import new file mode 100644 index 0000000..435f6a5 --- /dev/null +++ b/src/assets/gfx/character_select/dwarf.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3vkon4wpa0kw" +path="res://.godot/imported/dwarf.png-af088bfc09b3ad67a721acb4793a2349.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/character_select/dwarf.png" +dest_files=["res://.godot/imported/dwarf.png-af088bfc09b3ad67a721acb4793a2349.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/elf.png b/src/assets/gfx/character_select/elf.png new file mode 100644 index 0000000..d4c6a5c Binary files /dev/null and b/src/assets/gfx/character_select/elf.png differ 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 0000000..0fad327 Binary files /dev/null and b/src/assets/gfx/character_select/wizard.png differ 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 0000000..e61f360 Binary files /dev/null and b/src/assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png differ 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 0000000..4051c38 Binary files /dev/null and b/src/assets/gfx/enemies/boss/SpiderBat/down_right.png differ 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 0000000..bfd4320 Binary files /dev/null and b/src/assets/gfx/enemies/boss/SpiderBat/flying_down.png differ 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 0000000..7032968 Binary files /dev/null and b/src/assets/gfx/enemies/boss/SpiderBat/right.png differ diff --git a/src/assets/gfx/enemies/boss/SpiderBat/right.png.import b/src/assets/gfx/enemies/boss/SpiderBat/right.png.import new file mode 100644 index 0000000..8b46566 --- /dev/null +++ b/src/assets/gfx/enemies/boss/SpiderBat/right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsb72u2au1rkq" +path="res://.godot/imported/right.png-750fa141b376a0cc18fd164159e95203.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/enemies/boss/SpiderBat/right.png" +dest_files=["res://.godot/imported/right.png-750fa141b376a0cc18fd164159e95203.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/project.godot b/src/project.godot index 57b0a95..06bad13 100644 --- a/src/project.godot +++ b/src/project.godot @@ -23,6 +23,7 @@ buses/default_bus_layout="uid://psistrevppd1" [autoload] +GameState="*res://scripts/game_state.gd" NetworkManager="*res://scripts/network_manager.gd" LogManager="*res://scripts/log_manager.gd" diff --git a/src/scenes/attack_axe_swing.tscn b/src/scenes/attack_axe_swing.tscn index a61deb8..4769ebb 100644 --- a/src/scenes/attack_axe_swing.tscn +++ b/src/scenes/attack_axe_swing.tscn @@ -5,6 +5,7 @@ [ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_v2p0x"] [ext_resource type="AudioStream" uid="uid://uerx5rib87a6" path="res://assets/audio/sfx/weapons/bone_hit_wall_01.wav.mp3" id="4_ul7bj"] [ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="5_whqew"] +[ext_resource type="Texture2D" uid="uid://7r43xnr812km" path="res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png" id="6_whfey"] [sub_resource type="Animation" id="Animation_6bxep"] length = 0.001 @@ -46,12 +47,609 @@ _data = { [sub_resource type="RectangleShape2D" id="RectangleShape2D_3jdng"] size = Vector2(12, 12) +[sub_resource type="Animation" id="Animation_whfey"] +length = 0.001 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [5] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(0, 0)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [0.44505897] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(0, 0)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_6tili"] +resource_name = "new_animation" +length = 0.26982978 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10101918, 0.1907425), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [5, 6, 7, 8] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.039631248, 0.04101525, 0.08727307, 0.100975364, 0.120369375, 0.13681212, 0.15641695, 0.19119969, 0.1968914, 0.26877576), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-8.385, -7.685), Vector2(-8.385, -7.685), Vector2(-8.385, -8.64), Vector2(-8.385, -8.64), Vector2(-12.263, -0.402), Vector2(-8.866, 9.504), Vector2(-2.31, 12.034), Vector2(1.625, 11.899), Vector2(8.165, 6.394), Vector2(8.165, 6.394), Vector2(8.165, 2.224)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.040804446, 0.08727307, 0.13688956, 0.1914105, 0.19731301, 0.26898658), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [0.44505897, 0.44505897, 0.44505897, -2.199114857512855, -3.6651914291880923, -3.6651914, -4.091051766674709] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.2527546), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-32.05, 0), Vector2(-29.735, 12.04), Vector2(-20.673, 11.571), Vector2(-9.739, 9.129), Vector2(-9.254, 3.723)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_0lt3b"] +resource_name = "attack_down_left" +length = 0.27031633 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03993078, 0.10122998, 0.19097532), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [250, 251, 252, 253] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.040279698, 0.0775326, 0.10221264, 0.19371508), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-6.435, -8), Vector2(-7.305, -7.955), Vector2(-11.835, -3.09), Vector2(-11.74, 4.555), Vector2(-6.375, 11.365)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.040279698, 0.077299766, 0.10174698, 0.19301659), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 0, +"values": [0.4398229715025708, 0.3909537524467294, -0.24783675378319459, -1.0471975511965976, -2.0298179200694055] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00044363513, 0.07292879, 0.1013844, 0.1403958, 0.18827045), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1.5157166), +"update": 0, +"values": [Vector2(-25.874, -8.58), Vector2(-31.929, -4.05), Vector2(-33.3, 4.63), Vector2(-30.693, 8.711), Vector2(-27.919, 11.414)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_3ek3x"] +resource_name = "attack_down_right" +length = 0.26982978 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [40, 41, 42, 43] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.039631248, 0.04101525, 0.09401881, 0.09971053, 0.11003995, 0.12395305, 0.13681212, 0.15726018, 0.26898655), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-8.385, -7.685), Vector2(-8.385, -7.685), Vector2(-8.385, -8.64), Vector2(-8.385, -8.64), Vector2(-9.993, 7.435), Vector2(-4.231, 11.549), Vector2(2.785, 11.038), Vector2(9.175, 4.754), Vector2(8.95, 2.804), Vector2(8.95, 2.804)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.040804446, 0.093597196, 0.10076456, 0.10982916, 0.13710035, 0.15747097, 0.26919734), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [0.44505897, 0.44505897, 0.44505897, -1.6423548261266645, -2.1415189921970423, -3.743731245527837, -4.036946559862884, -4.036946559862884] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.2527546), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-28.065, 4.33), Vector2(-17.055, 8.945), Vector2(-11.473, 9.766), Vector2(-9.739, 4.169), Vector2(-9.254, 2.583)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_3qvhs"] +resource_name = "attack_left" +length = 0.27031633 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.19097532), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [215, 216, 217, 218] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.040279698, 0.0775326, 0.10221264, 0.19371508), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(11.015, -5.165), Vector2(8.29, -7.325), Vector2(-1.53, -8.86), Vector2(-10.76, -1.445), Vector2(-10.47, 8.395)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.040279698, 0.077299766, 0.10174698, 0.19324942), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 0, +"values": [1.722639971718403, 1.349139511791617, 0.49741883681838395, -0.3019419605950192, -1.4364059743913333] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00044363513, 0.07292879, 0.1013844, 0.1403958, 0.18803762), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-9.294, -5.345), Vector2(-19.749, -8.55), Vector2(-32.415, -5.325), Vector2(-32.588, 0.961), Vector2(-32.564, 7.954)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_58psj"] +resource_name = "attack_right" +length = 0.26982978 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [75, 76, 77, 77] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.04101525, 0.09401881, 0.09971053, 0.11003995, 0.12395305, 0.13681212, 0.15726018, 0.26898655), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-6.16, -7.685), Vector2(-6.16, -8.64), Vector2(-6.16, -8.64), Vector2(-9.993, 7.435), Vector2(-4.231, 11.549), Vector2(2.785, 11.038), Vector2(9.175, 4.754), Vector2(8.95, 2.804), Vector2(10.545, -0.231)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.040804446, 0.093597196, 0.10076456, 0.10982916, 0.13710035, 0.15747097, 0.26919734), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [0.44505897, 0.44505897, 0.44505897, -1.6423548261266645, -2.1415189921970423, -3.743731245527837, -4.036946559862884, -4.356341812977846] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.12163421, 0.1403958, 0.18803762, 0.26814333), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-28.065, 4.33), Vector2(-17.055, 8.945), Vector2(-11.473, 9.766), Vector2(-9.739, 4.169), Vector2(-5.094, 0.598)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 0, +"values": [Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_b4m4v"] +resource_name = "attack_up" +length = 0.27031633 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [145, 146, 147, 148] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.070071764, 0.075806916, 0.07635719, 0.114121936, 0.14789572, 0.19222274, 0.22793879, 0.2691677), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(8.04, -7.355), Vector2(8.225, -8.145), Vector2(8.04, -9.18), Vector2(8.04, -9.18), Vector2(12, 0.49), Vector2(7.546, -5.247), Vector2(-4.945, -10.381), Vector2(-7.771, -5.086), Vector2(-8.07, -1.565), Vector2(-8.07, -1.565)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.07536515, 0.07555858, 0.07566956, 0.114121936, 0.1478039, 0.19203907, 0.22792329, 0.2691522), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -4.134684997974567, -4.717624968140673, -6.005677956112488, -6.283185307179586, -6.806784082777885, -6.806784082777885] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.09463231, 0.1403958, 0.18803762, 0.26837614), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-7, 2.075), Vector2(-19.748, -11.654), Vector2(-28.329, -7.576), Vector2(-30.434, -2.707)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0, 0.07590186, 0.07619396, 0.26969346), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 0, +"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_r6tgr"] +resource_name = "attack_up_left" +length = 0.27031633 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.19097532), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [180, 181, 182, 183] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.0696061, 0.071848795, 0.07239907, 0.09828946, 0.10197981, 0.14789572, 0.19222274, 0.22793879, 0.2691677), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(3.075, -7.355), Vector2(3.385, -8.145), Vector2(4.125, -9.18), Vector2(8.04, -9.18), Vector2(7.46, -4.12), Vector2(-3.684, -5.822), Vector2(-5.898, -5.584), Vector2(-10.925, -0.251), Vector2(-12.136, 4.719), Vector2(-11.89, 4.77), Vector2(-11.89, 4.77)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.07140703, 0.07160046, 0.071711436, 0.09875512, 0.1478039, 0.19203907, 0.22792329, 0.2691522), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 0.8408964, 1, 1, 1, 1), +"update": 0, +"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -4.714134309636685, -5.782275811857214, -6.806784082777885, -7.3303828583761845, -7.3303828583761845, -7.3303828583761845] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.07292879, 0.09486514, 0.1403958, 0.18803762, 0.26837614), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-10.474, -5.42), Vector2(-20.235, -7.885), Vector2(-32.358, -3.184), Vector2(-34.939, 3.129), Vector2(-34.469, 4.153)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0, 0.07194374, 0.07223584, 0.26969346), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 0, +"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)] +} + +[sub_resource type="Animation" id="Animation_masq6"] +resource_name = "attack_up_right" +length = 0.27031633 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("PlayerTest:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.04016361, 0.10122998, 0.1907425), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [110, 111, 112, 113] +} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Sprite2D:position") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.06983893, 0.09338019, 0.09405232, 0.10155232, 0.107935, 0.17927958, 0.27015913), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(3.885, -7.355), Vector2(3.885, -8.145), Vector2(3.885, -8.145), Vector2(4.89, 7.675), Vector2(4.89, 7.675), Vector2(4.89, 7.675), Vector2(11.41, 4.38), Vector2(11.52, -0.24), Vector2(8.145, -7.405), Vector2(8.145, -7.405)] +} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Sprite2D:rotation") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = { +"times": PackedFloat32Array(0, 0.04068527, 0.069488026, 0.06982343, 0.093364686, 0.09403682, 0.10157588, 0.10791949, 0.17904675, 0.26969346), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 0, +"values": [-0.5131268000863325, -0.5131268000863325, -0.5131268000863325, -3.2288591161895095, -3.2288591161895095, -3.2288591161895095, -3.9217548292312583, -4.508185457901353, -5.040510879759624, -5.040510879759624] +} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("DamageArea:position") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = { +"times": PackedFloat32Array(0.00021080452, 0.090856746, 0.099288926, 0.1403958, 0.18803762, 0.26837614), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), +"update": 0, +"values": [Vector2(-22.394, 0), Vector2(-22.394, 0), Vector2(-8.56, 5.56), Vector2(-8.968, -4.474), Vector2(-11.929, -10.041), Vector2(-12.239, -8.572)] +} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Sprite2D:scale") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = { +"times": PackedFloat32Array(0, 0.06944155, 0.0697763, 0.26969346), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 0, +"values": [Vector2(-1, 1), Vector2(-1, 1), Vector2(1, 1), Vector2(1, 1)] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_3ek3x"] +_data = { +&"RESET": SubResource("Animation_whfey"), +&"attack_down": SubResource("Animation_6tili"), +&"attack_down_left": SubResource("Animation_0lt3b"), +&"attack_down_right": SubResource("Animation_3ek3x"), +&"attack_left": SubResource("Animation_3qvhs"), +&"attack_right": SubResource("Animation_58psj"), +&"attack_up": SubResource("Animation_b4m4v"), +&"attack_up_left": SubResource("Animation_r6tgr"), +&"attack_up_right": SubResource("Animation_masq6") +} + [node name="AxeSwing" type="Node2D" unique_id=1568208090] z_index = 10 y_sort_enabled = true script = ExtResource("1_xo3v0") +[node name="PlayerTest" type="Sprite2D" parent="." unique_id=1790922634] +visible = false +texture = ExtResource("6_whfey") +hframes = 35 +vframes = 8 +frame = 5 + [node name="Sprite2D" type="Sprite2D" parent="." unique_id=461038063] +z_index = 1 +rotation = 0.44505897 texture = ExtResource("2_hb10f") hframes = 20 vframes = 14 @@ -66,10 +664,11 @@ pitch_scale = 0.74 autoplay = true [node name="DamageArea" type="Area2D" parent="." unique_id=985585639] -collision_layer = 0 -collision_mask = 75 +collision_layer = 4 +collision_mask = 3 [node name="CollisionShape2D" type="CollisionShape2D" parent="DamageArea" unique_id=805714782] +position = Vector2(20, 0) shape = SubResource("RectangleShape2D_3jdng") debug_color = Color(0.7, 0, 0.18232, 0.42) @@ -85,5 +684,8 @@ volume_db = -5.622 pitch_scale = 1.43 max_polyphony = 4 +[node name="SwingAnimation" type="AnimationPlayer" parent="." unique_id=1169276342] +libraries/ = SubResource("AnimationLibrary_3ek3x") + [connection signal="area_entered" from="DamageArea" to="." method="_on_damage_area_area_entered"] [connection signal="body_entered" from="DamageArea" to="." method="_on_damage_area_body_entered"] diff --git a/src/scenes/attack_punch.tscn b/src/scenes/attack_punch.tscn new file mode 100644 index 0000000..cbeceaa --- /dev/null +++ b/src/scenes/attack_punch.tscn @@ -0,0 +1,43 @@ +[gd_scene format=3 uid="uid://hldlevntj8c2"] + +[ext_resource type="Script" uid="uid://ddqd1nlmsb8k6" path="res://scripts/attack_punch.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] +[ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_swosh"] +[ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="4_impact"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_punch"] + +[node name="AttackPunch" type="Node2D" unique_id=6975553] +z_index = 4 +script = ExtResource("1_script") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1707910142] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 108 + +[node name="Area2D" type="Area2D" parent="." unique_id=1216417398] +collision_layer = 4 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=689895866] +shape = SubResource("RectangleShape2D_punch") + +[node name="SfxSwosh" type="AudioStreamPlayer2D" parent="." unique_id=1177568406] +stream = ExtResource("3_swosh") +pitch_scale = 0.67 +max_distance = 983.0 +attenuation = 7.999991 +panning_strength = 1.1 +bus = &"Sfx" + +[node name="SfxImpact" type="AudioStreamPlayer2D" parent="." unique_id=1273542180] +stream = ExtResource("4_impact") +volume_db = -5.622 +pitch_scale = 1.43 +max_distance = 983.0 +attenuation = 7.999991 +max_polyphony = 4 +panning_strength = 1.16 +bus = &"Sfx" diff --git a/src/scenes/attack_spell_frostspike.tscn b/src/scenes/attack_spell_frostspike.tscn index 0b2182d..e818da0 100644 --- a/src/scenes/attack_spell_frostspike.tscn +++ b/src/scenes/attack_spell_frostspike.tscn @@ -43,5 +43,6 @@ shape = SubResource("RectangleShape2D_frost") stream = ExtResource("3_y7fsv") max_distance = 1456.0 attenuation = 5.4641595 +max_polyphony = 3 panning_strength = 1.06 bus = &"Sfx" diff --git a/src/scenes/damage_effect_arrow.tscn b/src/scenes/damage_effect_arrow.tscn new file mode 100644 index 0000000..6c071cb --- /dev/null +++ b/src/scenes/damage_effect_arrow.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://damage_effect_arrow"] + +[ext_resource type="Script" path="res://scripts/damage_effect_arrow.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[node name="DamageEffectArrow" type="Node2D"] +z_index = 5 +script = ExtResource("1_script") + +[node name="FxSprite" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 335 diff --git a/src/scenes/damage_effect_axe.tscn b/src/scenes/damage_effect_axe.tscn new file mode 100644 index 0000000..45ef427 --- /dev/null +++ b/src/scenes/damage_effect_axe.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://damage_effect_axe"] + +[ext_resource type="Script" path="res://scripts/damage_effect_axe.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[node name="DamageEffectAxe" type="Node2D"] +z_index = 5 +script = ExtResource("1_script") + +[node name="FxSprite" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 1158 diff --git a/src/scenes/damage_effect_bite.tscn b/src/scenes/damage_effect_bite.tscn new file mode 100644 index 0000000..1cd44f1 --- /dev/null +++ b/src/scenes/damage_effect_bite.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://damage_effect_bite"] + +[ext_resource type="Script" path="res://scripts/damage_effect_bite.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[node name="DamageEffectBite" type="Node2D"] +z_index = 5 +script = ExtResource("1_script") + +[node name="FxSprite" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 148 diff --git a/src/scenes/damage_effect_punch.tscn b/src/scenes/damage_effect_punch.tscn new file mode 100644 index 0000000..549cf1b --- /dev/null +++ b/src/scenes/damage_effect_punch.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://damage_effect_punch"] + +[ext_resource type="Script" path="res://scripts/damage_effect_punch.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[node name="DamageEffectPunch" type="Node2D"] +z_index = 5 +script = ExtResource("1_script") + +[node name="FxSprite" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 451 diff --git a/src/scenes/damage_effect_slash.tscn b/src/scenes/damage_effect_slash.tscn new file mode 100644 index 0000000..c67292f --- /dev/null +++ b/src/scenes/damage_effect_slash.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://damage_effect_slash"] + +[ext_resource type="Script" path="res://scripts/damage_effect_slash.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] + +[node name="DamageEffectSlash" type="Node2D"] +z_index = 5 +script = ExtResource("1_script") + +[node name="FxSprite" type="Sprite2D" parent="."] +texture = ExtResource("2_tex") +hframes = 105 +vframes = 79 +frame = 948 diff --git a/src/scenes/game_world.tscn b/src/scenes/game_world.tscn index 21b06ce..915256d 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -114,10 +114,10 @@ color = Color(0.69140625, 0.69140625, 0.69140625, 1) [node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465] stream = ExtResource("6_6c6v5") -volume_db = -20.411 +volume_db = -80.0 autoplay = true bus = &"Sfx" [node name="BgMusic" type="AudioStreamPlayer" parent="." unique_id=925983703] stream = ExtResource("8_pdbwf") -volume_db = -15.864 +volume_db = -80.0 diff --git a/src/scenes/main_menu.tscn b/src/scenes/main_menu.tscn index dc41447..f4851db 100644 --- a/src/scenes/main_menu.tscn +++ b/src/scenes/main_menu.tscn @@ -10,6 +10,10 @@ layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 +offset_left = 2.0 +offset_top = -9.0 +offset_right = 2.0 +offset_bottom = -9.0 grow_horizontal = 2 grow_vertical = 2 @@ -131,6 +135,10 @@ autowrap_mode = 2 custom_minimum_size = Vector2(0, 20) layout_mode = 2 +[node name="SelectRaceButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=1061067009] +layout_mode = 2 +text = "Choose race" + [node name="HostButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=268532531] layout_mode = 2 text = "Host Game" diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 20cbb90..fc49e80 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -702,12 +702,6 @@ collision_mask = 3 shape = SubResource("CircleShape2D_2") debug_color = Color(0.70196074, 0.6126261, 0.19635464, 0.41960785) -[node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470] -visible = false -modulate = Color(1, 1, 0, 0.5) -position = Vector2(0, -12) -scale = Vector2(4, 4) - [node name="Label" type="Label" parent="." unique_id=227628720] offset_left = -10.0 offset_top = -15.0 @@ -715,6 +709,12 @@ offset_right = 10.0 offset_bottom = -9.0 horizontal_alignment = 1 +[node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470] +visible = false +modulate = Color(1, 1, 0, 0.5) +position = Vector2(0, -12) +scale = Vector2(4, 4) + [node name="SfxWalk" type="AudioStreamPlayer2D" parent="." unique_id=1693322702] stream = SubResource("AudioStreamRandomizer_l71n6") volume_db = -18.527 diff --git a/src/scenes/select_class.tscn b/src/scenes/select_class.tscn new file mode 100644 index 0000000..bb5b878 --- /dev/null +++ b/src/scenes/select_class.tscn @@ -0,0 +1,130 @@ +[gd_scene format=3 uid="uid://ljvf241rr1qj"] + +[ext_resource type="Texture2D" uid="uid://dp8hmks54a5pn" path="res://assets/gfx/character_select/characters2.png" id="1_f45sh"] +[ext_resource type="Texture2D" uid="uid://cj3adlbm2ksl4" path="res://assets/gfx/character_select/characters.png" id="1_fiono"] +[ext_resource type="Script" uid="uid://cwbrfwrwt3krh" path="res://scripts/select_class.gd" id="1_script"] +[ext_resource type="Texture2D" uid="uid://c3vkon4wpa0kw" path="res://assets/gfx/character_select/dwarf.png" id="2_0qyep"] +[ext_resource type="Texture2D" uid="uid://b3r1hyhgtv7sf" path="res://assets/gfx/character_select/elf.png" id="3_ewbj8"] +[ext_resource type="FontFile" uid="uid://bajcvmidrnc33" path="res://assets/fonts/standard_font.png" id="3_f45sh"] +[ext_resource type="Texture2D" uid="uid://d3qv0erfu3xtl" path="res://assets/gfx/character_select/wizard.png" id="4_f45sh"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bsj10"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_8nlan"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4b24w"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1si1m"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gkv83"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3328y"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q1cbd"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_yyf7y"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_7d5tl"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1itgt"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_8xviw"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_oflmk"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_rl1cb"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_x8w6g"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_uihst"] + +[node name="SelectClass" type="Node2D" unique_id=126568820] +script = ExtResource("1_script") + +[node name="Characters2" type="Sprite2D" parent="." unique_id=2011779728] +texture = ExtResource("1_f45sh") + +[node name="Characters" type="Sprite2D" parent="Characters2" unique_id=1197243452] +modulate = Color(0.9472463, 0.94050175, 1, 1) +texture = ExtResource("1_fiono") + +[node name="Dwarf" type="Sprite2D" parent="." unique_id=1905014075] +visible = false +texture = ExtResource("2_0qyep") + +[node name="Label" type="Label" parent="Dwarf" unique_id=1691050946] +offset_left = -88.0 +offset_top = 82.0 +offset_right = -48.0 +offset_bottom = 105.0 +theme_override_fonts/font = ExtResource("3_f45sh") +theme_override_font_sizes/font_size = 8 +text = "DWARF" + +[node name="Elf" type="Sprite2D" parent="." unique_id=172131227] +visible = false +texture = ExtResource("3_ewbj8") + +[node name="Label" type="Label" parent="Elf" unique_id=1853931362] +offset_left = 48.0 +offset_top = 82.0 +offset_right = 88.0 +offset_bottom = 105.0 +theme_override_fonts/font = ExtResource("3_f45sh") +theme_override_font_sizes/font_size = 8 +text = "ELF" + +[node name="Wizard" type="Sprite2D" parent="." unique_id=718483736] +visible = false +texture = ExtResource("4_f45sh") + +[node name="Label" type="Label" parent="Wizard" unique_id=244634907] +offset_left = -26.0 +offset_top = 85.0 +offset_right = 22.0 +offset_bottom = 108.0 +theme_override_fonts/font = ExtResource("3_f45sh") +theme_override_font_sizes/font_size = 8 +text = "WIZARD" + +[node name="Control" type="Control" parent="." unique_id=952415672] +layout_mode = 3 +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="ButtonDwarf" type="Button" parent="Control" unique_id=70318818] +layout_mode = 0 +offset_left = -116.0 +offset_top = -27.0 +offset_right = -44.0 +offset_bottom = 90.0 +theme_override_styles/normal = SubResource("StyleBoxEmpty_bsj10") +theme_override_styles/pressed = SubResource("StyleBoxEmpty_8nlan") +theme_override_styles/hover = SubResource("StyleBoxEmpty_4b24w") +theme_override_styles/disabled = SubResource("StyleBoxEmpty_1si1m") +theme_override_styles/focus = SubResource("StyleBoxEmpty_gkv83") + +[node name="ButtonElf" type="Button" parent="Control" unique_id=860222517] +layout_mode = 0 +offset_left = 37.0 +offset_top = -32.0 +offset_right = 126.0 +offset_bottom = 90.0 +theme_override_styles/normal = SubResource("StyleBoxEmpty_3328y") +theme_override_styles/pressed = SubResource("StyleBoxEmpty_q1cbd") +theme_override_styles/hover = SubResource("StyleBoxEmpty_yyf7y") +theme_override_styles/disabled = SubResource("StyleBoxEmpty_7d5tl") +theme_override_styles/focus = SubResource("StyleBoxEmpty_1itgt") + +[node name="ButtonWizard" type="Button" parent="Control" unique_id=170883452] +layout_mode = 0 +offset_left = -44.0 +offset_top = -65.0 +offset_right = 37.0 +offset_bottom = 94.0 +theme_override_styles/normal = SubResource("StyleBoxEmpty_8xviw") +theme_override_styles/pressed = SubResource("StyleBoxEmpty_oflmk") +theme_override_styles/hover = SubResource("StyleBoxEmpty_rl1cb") +theme_override_styles/disabled = SubResource("StyleBoxEmpty_x8w6g") +theme_override_styles/focus = SubResource("StyleBoxEmpty_uihst") diff --git a/src/scripts/attack_arrow.gd b/src/scripts/attack_arrow.gd index 4d0ad40..6c2373e 100644 --- a/src/scripts/attack_arrow.gd +++ b/src/scripts/attack_arrow.gd @@ -2,12 +2,14 @@ extends CharacterBody2D var speed = 300 var direction = Vector2.ZERO -var stick_duration = 3.0 # How long the arrow stays stuck to enemies/players +var stick_duration = 3.0 # How long the arrow stays stuck to players +var stick_duration_enemy = 1.2 # Shorter when stuck to enemy - fade out faster var wall_stick_duration = 30.0 # Much longer duration for wall-stuck arrows (30 seconds) var is_stuck = false var is_collected = false var stick_timer = 0.0 var stuck_to_wall = false # Track if stuck to wall (vs enemy/player) +var stuck_to_enemy = false # Track if stuck to enemy (use shorter duration) var can_be_collected = false # True after delay on wall var shooter_can_collect = false # Shooter can collect after 0.2 seconds var shooter_collection_delay = 0.2 # Fast pickup for shooter @@ -15,12 +17,12 @@ var others_collection_delay = 5.0 # Other players wait 5 seconds # Flight duration based on charge var flight_timer = 0.0 -var max_flight_duration = 6.0 # How long arrow flies before landing (set by charge) -var can_deal_damage = true # False after arrow "lands" in flight +var max_flight_duration = 6.0 # How long arrow flies before landing (set by charge) +var can_deal_damage = true # False after arrow "lands" in flight var initiated_by: Node2D = null -var player_owner: Node = null # Like sword_projectile -var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) +var player_owner: Node = null # Like sword_projectile +var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) # Collection area for wall-stuck arrows var collection_area: Area2D = null @@ -66,7 +68,7 @@ func _initialize_arrow() -> 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: