diff --git a/src/assets/audio/music/Gelhein - Evil.mp3.import b/src/assets/audio/music/Gelhein - Evil.mp3.import index 23cef95..8939b34 100644 --- a/src/assets/audio/music/Gelhein - Evil.mp3.import +++ b/src/assets/audio/music/Gelhein - Evil.mp3.import @@ -12,8 +12,8 @@ dest_files=["res://.godot/imported/Gelhein - Evil.mp3-bc2ead9945dee5ecfc0f8aff80 [params] -loop=false -loop_offset=0 -bpm=0 +loop=true +loop_offset=0.0 +bpm=0.0 beat_count=0 bar_beats=4 diff --git a/src/assets/audio/sfx/level_complete_03.wav b/src/assets/audio/sfx/level_complete_03.wav new file mode 100644 index 0000000..c1b597f Binary files /dev/null and b/src/assets/audio/sfx/level_complete_03.wav differ diff --git a/src/assets/audio/sfx/level_complete_03.wav.import b/src/assets/audio/sfx/level_complete_03.wav.import new file mode 100644 index 0000000..557e020 --- /dev/null +++ b/src/assets/audio/sfx/level_complete_03.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://rj481ujhyho6" +path="res://.godot/imported/level_complete_03.wav-d1d7aa69f96bcf65cf182c6066013c49.sample" + +[deps] + +source_file="res://assets/audio/sfx/level_complete_03.wav" +dest_files=["res://.godot/imported/level_complete_03.wav-d1d7aa69f96bcf65cf182c6066013c49.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/src/assets/audio/sfx/level_fail_02.wav b/src/assets/audio/sfx/level_fail_02.wav new file mode 100644 index 0000000..6d317c3 Binary files /dev/null and b/src/assets/audio/sfx/level_fail_02.wav differ diff --git a/src/assets/audio/sfx/level_fail_02.wav.import b/src/assets/audio/sfx/level_fail_02.wav.import new file mode 100644 index 0000000..3a85cbe --- /dev/null +++ b/src/assets/audio/sfx/level_fail_02.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://csybukiq51h41" +path="res://.godot/imported/level_fail_02.wav-26d001ef22c866c4eb5cb80c7c2151ca.sample" + +[deps] + +source_file="res://assets/audio/sfx/level_fail_02.wav" +dest_files=["res://.godot/imported/level_fail_02.wav-26d001ef22c866c4eb5cb80c7c2151ca.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/src/assets/audio/sfx/player/notice/aha.mp3 b/src/assets/audio/sfx/player/notice/aha.mp3 new file mode 100644 index 0000000..7ee43c7 Binary files /dev/null and b/src/assets/audio/sfx/player/notice/aha.mp3 differ diff --git a/src/assets/audio/sfx/player/notice/aha.mp3.import b/src/assets/audio/sfx/player/notice/aha.mp3.import new file mode 100644 index 0000000..150d367 --- /dev/null +++ b/src/assets/audio/sfx/player/notice/aha.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://cs5ruoyq80yi4" +path="res://.godot/imported/aha.mp3-90367befe3910bf62cb2a22cf11ad3ce.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/player/notice/aha.mp3" +dest_files=["res://.godot/imported/aha.mp3-90367befe3910bf62cb2a22cf11ad3ce.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/audio/sfx/player/notice/lookout.mp3 b/src/assets/audio/sfx/player/notice/lookout.mp3 new file mode 100644 index 0000000..b99b774 Binary files /dev/null and b/src/assets/audio/sfx/player/notice/lookout.mp3 differ diff --git a/src/assets/audio/sfx/player/notice/lookout.mp3.import b/src/assets/audio/sfx/player/notice/lookout.mp3.import new file mode 100644 index 0000000..6e24798 --- /dev/null +++ b/src/assets/audio/sfx/player/notice/lookout.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://cbbeyrdor7nyg" +path="res://.godot/imported/lookout.mp3-0a409809ff1b9e10875c4707d9f8764e.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/player/notice/lookout.mp3" +dest_files=["res://.godot/imported/lookout.mp3-0a409809ff1b9e10875c4707d9f8764e.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3 b/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3 new file mode 100644 index 0000000..698970b Binary files /dev/null and b/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3 differ diff --git a/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3.import b/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3.import new file mode 100644 index 0000000..d53c07b --- /dev/null +++ b/src/assets/audio/sfx/player/notice/whatdowehavehere.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://bew7ciiygabaj" +path="res://.godot/imported/whatdowehavehere.mp3-efdb2c278c76a362c249cec370681a7f.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/player/notice/whatdowehavehere.mp3" +dest_files=["res://.godot/imported/whatdowehavehere.mp3-efdb2c278c76a362c249cec370681a7f.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/DragonKnightHelm.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/DragonKnightHelm.png index e36ae19..261d27b 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/DragonKnightHelm.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/DragonKnightHelm.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/GruntHelm.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/GruntHelm.png index 550715b..1118210 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/GruntHelm.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/GruntHelm.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/KnightHelm.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/KnightHelm.png index b345984..aad97af 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/KnightHelm.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/KnightHelm.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/NoviceHelm.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/NoviceHelm.png index 05feeb4..9889a1a 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/NoviceHelm.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/NoviceHelm.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/PaladinHelmCyan.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/PaladinHelmCyan.png index 5dd70b6..a2a90c0 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/PaladinHelmCyan.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/PaladinHelmCyan.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/ScoutHelmGreen.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/ScoutHelmGreen.png index bac6081..e02cb5f 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/ScoutHelmGreen.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/ScoutHelmGreen.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierBronzeHelmBlue.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierBronzeHelmBlue.png index ba1c2ab..94a0eec 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierBronzeHelmBlue.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierBronzeHelmBlue.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierIronHelmBlue.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierIronHelmBlue.png index 10d8a2b..3ef775c 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierIronHelmBlue.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierIronHelmBlue.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierSteelHelmBlue.png b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierSteelHelmBlue.png index 4eb3677..2b48fcb 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierSteelHelmBlue.png and b/src/assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Melee/SoldierSteelHelmBlue.png differ diff --git a/src/scenes/enemy_humanoid.tscn b/src/scenes/enemy_humanoid.tscn index 036cc1a..d7add3d 100644 --- a/src/scenes/enemy_humanoid.tscn +++ b/src/scenes/enemy_humanoid.tscn @@ -14,6 +14,22 @@ [ext_resource type="AudioStream" uid="uid://cbvxokqp1bxar" path="res://assets/audio/sfx/enemies/goblin/raargh3.mp3" id="11_5x2ph"] [ext_resource type="AudioStream" uid="uid://dscx61fdkejlt" path="res://assets/audio/sfx/enemies/goblin/ive_been_waiting_for_this.mp3" id="12_oynfq"] [ext_resource type="AudioStream" uid="uid://ban8uv8hifsgc" path="res://assets/audio/sfx/enemies/goblin/stay_back_if_you_wanna_keep_your_head.mp3" id="13_b0veo"] +[ext_resource type="Texture2D" uid="uid://bkca7nmt4du5e" path="res://assets/gfx/Puny-Characters/ShieldOverlayer.png" id="14_shield"] +[ext_resource type="Texture2D" uid="uid://bpxxpdpow5qyl" path="res://assets/gfx/Puny-Characters/ShieldOverlayerHolding.png" id="15_shieldh"] +[ext_resource type="PackedScene" path="res://scenes/incantation.tscn" id="16_inc"] +[ext_resource type="AudioStream" uid="uid://c10ju1f6d4ed3" path="res://assets/audio/sfx/shield/activate_shield.wav" id="17_sfx"] +[ext_resource type="AudioStream" uid="uid://ly1euk0v3jxy" path="res://assets/audio/sfx/shield/shield.wav" id="18_sfx"] +[ext_resource type="AudioStream" uid="uid://c4lh535yj010h" path="res://assets/audio/sfx/shield/shield1.wav" id="19_sfx"] +[ext_resource type="AudioStream" uid="uid://ch3p57i7fvd1v" path="res://assets/audio/sfx/shield/shield2.wav" id="20_sfx"] +[ext_resource type="AudioStream" uid="uid://t0sg2rxlfech" path="res://assets/audio/sfx/shield/shield3.wav" id="21_sfx"] +[ext_resource type="AudioStream" uid="uid://b6klanrso0vvq" path="res://assets/audio/sfx/weapons/bow/bow_release_1.mp3" id="22_sfx"] +[ext_resource type="AudioStream" uid="uid://b6mwlp2ap0wbj" path="res://assets/audio/sfx/weapons/bow/bow_release2.mp3" id="23_sfx"] +[ext_resource type="AudioStream" uid="uid://d1ut5lnlch0k2" path="res://assets/audio/sfx/weapons/bow/bow_release3.mp3" id="24_sfx"] +[ext_resource type="AudioStream" uid="uid://mbtpqlb5n3gd" path="res://assets/audio/sfx/weapons/bow/bow_no_arrow.mp3" id="25_sfx"] +[ext_resource type="AudioStream" uid="uid://cgya50qrx8gms" path="res://assets/audio/sfx/weapons/bow/buckle_bow.mp3" id="26_sfx"] +[ext_resource type="AudioStream" uid="uid://bvi00vbftbgc5" path="res://assets/audio/sfx/player/ultra_run/shinespark_start.wav" id="27_sfx"] +[ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="28_sfx"] +[ext_resource type="AudioStream" uid="uid://w6yon88kjfml" path="res://assets/audio/sfx/nickes/lift_sfx.ogg" id="29_sfx"] [sub_resource type="Gradient" id="Gradient_1"] colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) @@ -211,6 +227,40 @@ stream_2/stream = ExtResource("11_5x2ph") stream_3/stream = ExtResource("12_oynfq") stream_4/stream = ExtResource("13_b0veo") +[sub_resource type="ShaderMaterial" id="ShaderMaterial_shield"] +shader = ExtResource("4_r7ul0") +shader_parameter/original_0 = Color(0, 0, 0, 1) +shader_parameter/original_1 = Color(0, 0, 0, 1) +shader_parameter/original_2 = Color(0, 0, 0, 1) +shader_parameter/original_3 = Color(0, 0, 0, 1) +shader_parameter/original_4 = Color(0, 0, 0, 1) +shader_parameter/original_5 = Color(0, 0, 0, 1) +shader_parameter/original_6 = Color(0, 0, 0, 1) +shader_parameter/replace_0 = Color(0, 0, 0, 1) +shader_parameter/replace_1 = Color(0, 0, 0, 1) +shader_parameter/replace_2 = Color(0, 0, 0, 1) +shader_parameter/replace_3 = Color(0, 0, 0, 1) +shader_parameter/replace_4 = Color(0, 0, 0, 1) +shader_parameter/replace_5 = Color(0, 0, 0, 1) +shader_parameter/replace_6 = Color(0, 0, 0, 1) +shader_parameter/tint = Color(1, 1, 1, 1) + +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_bow"] +playback_mode = 1 +random_pitch = 1.0123794 +streams_count = 3 +stream_0/stream = ExtResource("22_sfx") +stream_1/stream = ExtResource("23_sfx") +stream_2/stream = ExtResource("24_sfx") + +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_block"] +playback_mode = 1 +streams_count = 4 +stream_0/stream = ExtResource("18_sfx") +stream_1/stream = ExtResource("19_sfx") +stream_2/stream = ExtResource("20_sfx") +stream_3/stream = ExtResource("21_sfx") + [node name="EnemyHumanoid" type="CharacterBody2D" unique_id=285357386] collision_layer = 2 collision_mask = 65 @@ -278,12 +328,28 @@ material = SubResource("ShaderMaterial_i1636") hframes = 35 vframes = 8 +[node name="Sprite2DShield" type="Sprite2D" parent="."] +visible = false +material = SubResource("ShaderMaterial_shield") +texture = ExtResource("14_shield") +hframes = 35 +vframes = 8 + +[node name="Sprite2DShieldHolding" type="Sprite2D" parent="."] +visible = false +material = SubResource("ShaderMaterial_shield") +texture = ExtResource("15_shieldh") +hframes = 35 +vframes = 8 + [node name="Sprite2DWeapon" type="Sprite2D" parent="." unique_id=1718282928] y_sort_enabled = true texture = ExtResource("4") hframes = 35 vframes = 8 +[node name="Incantation" parent="." instance=ExtResource("16_inc")] + [node name="AlertIndicator" type="Sprite2D" parent="." unique_id=1697001148] visible = false z_index = 100 @@ -330,3 +396,48 @@ attenuation = 8.57418 max_polyphony = 4 panning_strength = 1.04 bus = &"Sfx" + +[node name="SfxActivateShield" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("17_sfx") +volume_db = 9.695 +attenuation = 1.3660401 +panning_strength = 1.78 + +[node name="SfxBlockWithShield" type="AudioStreamPlayer2D" parent="."] +stream = SubResource("AudioStreamRandomizer_block") +volume_db = 7.254 +attenuation = 1.3195078 +panning_strength = 1.06 +bus = &"Sfx" + +[node name="SfxBowShoot" type="AudioStreamPlayer2D" parent="."] +stream = SubResource("AudioStreamRandomizer_bow") +pitch_scale = 1.33 +attenuation = 6.7271657 + +[node name="SfxBowWithoutArrow" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("25_sfx") +max_distance = 1455.0 +attenuation = 7.4642572 + +[node name="SfxBuckleBow" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("26_sfx") +attenuation = 7.727478 +panning_strength = 1.03 + +[node name="SfxSpellCharge" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("27_sfx") + +[node name="SfxThrow" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("28_sfx") +pitch_scale = 0.61 +max_distance = 983.0 +attenuation = 8.876549 +panning_strength = 1.04 + +[node name="SfxLift" type="AudioStreamPlayer2D" parent="."] +stream = ExtResource("29_sfx") +max_distance = 1246.0 +attenuation = 1.9999994 +panning_strength = 1.11 +bus = &"Sfx" diff --git a/src/scenes/game_world.tscn b/src/scenes/game_world.tscn index 80eb2dc..21b06ce 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -108,16 +108,16 @@ y_sort_enabled = true script = ExtResource("5") [node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815] -visible = false light_mask = 1048575 visibility_layer = 1048575 color = Color(0.69140625, 0.69140625, 0.69140625, 1) -[node name="SfxWinds" type="AudioStreamPlayer2D" parent="." unique_id=1141138343] +[node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465] stream = ExtResource("6_6c6v5") -volume_db = -3.085 +volume_db = -20.411 autoplay = true +bus = &"Sfx" -[node name="BgMusic" type="AudioStreamPlayer2D" parent="." unique_id=628820950] +[node name="BgMusic" type="AudioStreamPlayer" parent="." unique_id=925983703] stream = ExtResource("8_pdbwf") -autoplay = true +volume_db = -15.864 diff --git a/src/scenes/incantation.tscn b/src/scenes/incantation.tscn new file mode 100644 index 0000000..f57fadf --- /dev/null +++ b/src/scenes/incantation.tscn @@ -0,0 +1,159 @@ +[gd_scene format=3 uid="uid://dwxyxlqdgclwi"] + +[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="1_tex"] + +[sub_resource type="Animation" id="Animation_reset"] +length = 0.001 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [2037] +} + +[sub_resource type="Animation" id="Animation_fire_ch"] +resource_name = "fire_charging" +length = 0.4 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053] +} + +[sub_resource type="Animation" id="Animation_fire_rdy"] +resource_name = "fire_ready" +length = 0.6 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.10000002, 0.13333336, 0.16666669, 0.20000002, 0.23333335, 0.26666668, 0.30000004, 0.33333337, 0.3666667, 0.4, 0.43333337, 0.46666673, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053] +} + +[sub_resource type="Animation" id="Animation_frost_ch"] +resource_name = "frost_charging" +length = 0.566 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.1, 0.13333334, 0.16666667, 0.2, 0.23333333, 0.26666668, 0.3, 0.33333334, 0.36666667, 0.4, 0.43333334, 0.46666667, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [3678, 3679, 3680, 3681, 3682, 3683, 3684, 3685, 3686, 3687, 3688, 3689, 3690, 3691, 3692, 3693, 3694] +} + +[sub_resource type="Animation" id="Animation_frost_rdy"] +resource_name = "frost_ready" +length = 0.566 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.03333334, 0.06666667, 0.1, 0.13333334, 0.16666667, 0.2, 0.23333333, 0.26666668, 0.3, 0.33333334, 0.36666667, 0.4, 0.43333334, 0.46666667, 0.5, 0.53333336), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [3678, 3679, 3680, 3681, 3682, 3683, 3684, 3685, 3686, 3687, 3688, 3689, 3690, 3691, 3692, 3693, 3694] +} + +[sub_resource type="Animation" id="Animation_heal_ch"] +resource_name = "healing_charging" +length = 0.5 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [589, 590, 591, 592, 593, 594, 595, 596, 597, 598] +} + +[sub_resource type="Animation" id="Animation_heal_rdy"] +resource_name = "healing_ready" +length = 0.5 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45), +"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"update": 1, +"values": [589, 590, 591, 592, 593, 594, 595, 596, 597, 598] +} + +[sub_resource type="Animation" id="Animation_idle"] +resource_name = "idle" +length = 0.1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("IncantationSprite:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [0] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_incant"] +_data = { +&"RESET": SubResource("Animation_reset"), +&"fire_charging": SubResource("Animation_fire_ch"), +&"fire_ready": SubResource("Animation_fire_rdy"), +&"frost_charging": SubResource("Animation_frost_ch"), +&"frost_ready": SubResource("Animation_frost_rdy"), +&"healing_charging": SubResource("Animation_heal_ch"), +&"healing_ready": SubResource("Animation_heal_rdy"), +&"idle": SubResource("Animation_idle") +} + +[node name="Incantation" type="Node2D"] + +[node name="IncantationSprite" type="Sprite2D" parent="."] +texture = ExtResource("1_tex") +hframes = 105 +vframes = 79 +frame = 2037 + +[node name="AnimationIncantation" type="AnimationPlayer" parent="."] +libraries/ = SubResource("AnimationLibrary_incant") +autoplay = &"idle" diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 1522b6c..20cbb90 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -47,6 +47,9 @@ [ext_resource type="AudioStream" uid="uid://dvq72502qa46f" path="res://assets/audio/sfx/shield/denied_activate_Shield2.wav" id="45_g5jhy"] [ext_resource type="AudioStream" uid="uid://8l0hx3sjh4ci" path="res://assets/audio/sfx/jsfxr/hitHurt (1).wav" id="46_holxr"] [ext_resource type="AudioStream" uid="uid://cjv4cf2kiomwo" path="res://assets/audio/sfx/jsfxr/bird_sound.wav" id="47_mx1m4"] +[ext_resource type="AudioStream" uid="uid://cbbeyrdor7nyg" path="res://assets/audio/sfx/player/notice/lookout.mp3" id="48_6e8lb"] +[ext_resource type="AudioStream" uid="uid://cs5ruoyq80yi4" path="res://assets/audio/sfx/player/notice/aha.mp3" id="49_2gdjj"] +[ext_resource type="AudioStream" uid="uid://bew7ciiygabaj" path="res://assets/audio/sfx/player/notice/whatdowehavehere.mp3" id="50_sc3ue"] [sub_resource type="Gradient" id="Gradient_wqfne"] colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) @@ -563,6 +566,12 @@ _data = { &"sleep": SubResource("Animation_mx1m4") } +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_lxlsd"] +random_pitch = 1.1036249 +streams_count = 2 +stream_0/stream = ExtResource("49_2gdjj") +stream_1/stream = ExtResource("50_sc3ue") + [node name="Player" type="CharacterBody2D" unique_id=937429705] collision_mask = 67 motion_mode = 1 @@ -740,8 +749,9 @@ panning_strength = 1.04 [node name="SfxLift" type="AudioStreamPlayer2D" parent="." unique_id=1261167113] stream = ExtResource("28_pf23h") max_distance = 1246.0 -attenuation = 6.964403 +attenuation = 1.9999994 panning_strength = 1.11 +bus = &"Sfx" [node name="DirectionalLight2D" type="DirectionalLight2D" parent="." unique_id=1013099358] visible = false @@ -802,6 +812,11 @@ bus = &"Sfx" stream = ExtResource("45_g5jhy") volume_db = 9.458 +[node name="SfxDeny" type="AudioStreamPlayer2D" parent="." unique_id=1239738372] +stream = ExtResource("45_g5jhy") +volume_db = 9.458 +bus = &"Sfx" + [node name="Sprite2DStatus" type="Sprite2D" parent="." unique_id=1335748461] position = Vector2(0, -10) texture = ExtResource("37_hax0n") @@ -825,3 +840,22 @@ volume_db = -13.255 attenuation = 3.2490087 panning_strength = 1.12 bus = &"Sfx" + +[node name="SfxLookOut" type="AudioStreamPlayer2D" parent="." unique_id=1177750193] +stream = ExtResource("48_6e8lb") +volume_db = 0.881 +max_distance = 1138.0 +attenuation = 7.999997 +panning_strength = 1.04 +bus = &"Sfx" + +[node name="SfxAhaa" type="AudioStreamPlayer2D" parent="." unique_id=1556952538] +stream = SubResource("AudioStreamRandomizer_lxlsd") +max_distance = 1496.0 +attenuation = 6.062864 +panning_strength = 1.13 +bus = &"Sfx" + +[node name="SfxDeny2" type="AudioStreamPlayer2D" parent="." unique_id=1127340261] +stream = ExtResource("45_g5jhy") +max_distance = 1619.0 diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd index 902be2a..90a4933 100644 --- a/src/scripts/attack_bomb.gd +++ b/src/scripts/attack_bomb.gd @@ -424,24 +424,24 @@ func _spawn_explosion_tile_particles(): # Direction from explosion center to this tile (outward) – particles fly away from bomb var to_tile = world - center var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU) - # Half the particles: 2 pieces per tile instead of 4 (indices 0 and 2) - for i in [0, 2]: - var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D - var spr = p.get_node_or_null("Sprite2D") as Sprite2D - if not spr: - p.queue_free() - continue - spr.texture = tex - spr.region_enabled = true - spr.region_rect = regions[i] - 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)) - p.velocity = d.normalized() * speed - p.angular_velocity = randf_range(-14.0, 14.0) - p.position_z = 0.0 - p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down - parent.add_child(p) + # Reduced particles: 1 piece per tile instead of 2 (use index 0) + var i = 0 + var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D + var spr = p.get_node_or_null("Sprite2D") as Sprite2D + if not spr: + p.queue_free() + continue + spr.texture = tex + spr.region_enabled = true + spr.region_rect = regions[i] + 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)) + p.velocity = d.normalized() * speed + p.angular_velocity = randf_range(-14.0, 14.0) + p.position_z = 0.0 + p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down + parent.add_child(p) func _cause_screenshake(): # Calculate screenshake based on distance from local players diff --git a/src/scripts/door.gd b/src/scripts/door.gd index 18ce982..a0a80a2 100644 --- a/src/scripts/door.gd +++ b/src/scripts/door.gd @@ -1345,4 +1345,14 @@ func _sync_door_close(): is_closing = true move_timer = 0.0 + # Play close sound on clients (host plays it in _close()) + if type == "GateDoor": + var sfx = get_node_or_null("SfxCloseGateDoor") + if sfx and sfx.stream: + sfx.play() + else: + var sfx = get_node_or_null("SfxDoorCloses") + if sfx and sfx.stream: + sfx.play() + LogManager.log("Door: Client received door close RPC for " + str(name) + " - starting close animation", LogManager.CATEGORY_DOOR) diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index b435f23..8069e79 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -281,7 +281,19 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - if stairs_data.is_empty(): LogManager.log_error("DungeonGenerator: CRITICAL ERROR - Could not place stairs even with force placement!", LogManager.CATEGORY_DUNGEON) - # 8. Place torches in rooms (AFTER stairs, so torches don't overlap stairs) + # 7.6. Place entrance in start room (only for level > 1) BEFORE placing torches + var entrance_data = {} + if level > 1: + entrance_data = _place_entrance_in_start_room(all_rooms[start_room_index], grid, tile_grid, map_size, all_doors, rng) + if entrance_data.is_empty(): + LogManager.log_error("DungeonGenerator: ERROR - Failed to place entrance in start room! Room size: " + str(all_rooms[start_room_index].w) + "x" + str(all_rooms[start_room_index].h) + " Doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) + # CRITICAL: Force place entrance - we MUST have an entrance for level > 1! + LogManager.log("DungeonGenerator: FORCING entrance placement in start room", LogManager.CATEGORY_DUNGEON) + entrance_data = _force_place_stairs(all_rooms[start_room_index], grid, tile_grid, map_size, all_doors, rng) + if entrance_data.is_empty(): + LogManager.log_error("DungeonGenerator: CRITICAL ERROR - Could not place entrance even with force placement!", LogManager.CATEGORY_DUNGEON) + + # 8. Place torches in rooms (AFTER stairs/entrance, so torches don't overlap) var all_torches = [] for room in all_rooms: var room_torches = _place_torches_in_room(room, grid, all_doors, map_size, rng) @@ -353,6 +365,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - "interactable_objects": all_interactable_objects, "traps": all_traps, "stairs": stairs_data, + "entrance": entrance_data, "blocking_doors": blocking_doors, "grid": grid, "tile_grid": tile_grid, @@ -1257,7 +1270,8 @@ func _place_enemies_in_room(room: Dictionary, grid: Array, map_size: Vector2i, r "res://scenes/enemy_rat.tscn", "res://scenes/enemy_humanoid.tscn", "res://scenes/enemy_slime.tscn", - "res://scenes/enemy_bat.tscn" + "res://scenes/enemy_bat.tscn", + "res://scenes/enemy_hand.tscn" ] # Find valid floor positions in the room (excluding walls) @@ -1298,6 +1312,8 @@ func _place_enemies_in_room(room: Dictionary, grid: Array, map_size: Vector2i, r move_speed = rng.randf_range(18.0, 25.0) # Slimes: very slow (reduced from 30-40) elif enemy_type.ends_with("rat.tscn"): move_speed = rng.randf_range(40.0, 50.0) # Rats: slow + elif enemy_type.ends_with("hand.tscn"): + move_speed = rng.randf_range(25.0, 32.0) # Hands: slow (reduced from 55.0 to 28.0 in script) else: move_speed = rng.randf_range(50.0, 80.0) # Other enemies (humanoids): faster @@ -1606,6 +1622,15 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A return stairs_data +func _place_entrance_in_start_room(start_room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, rng: RandomNumberGenerator) -> Dictionary: + # Place entrance in the start room (similar to stairs but for entrance) + # Entrance is rendered like stairs but with different purpose + # Returns entrance data with position and size for Area2D creation + LogManager.log("DungeonGenerator: Placing entrance in start room: " + str(start_room.x) + "," + str(start_room.y) + " size: " + str(start_room.w) + "x" + str(start_room.h) + " doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) + + # Reuse the stairs placement logic (entrance looks like stairs) + return _place_stairs_in_exit_room(start_room, grid, tile_grid, map_size, all_doors, rng) + func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, _rng: RandomNumberGenerator) -> Dictionary: # Force place stairs in exit room - used as fallback when normal placement fails # Still tries to avoid door overlaps, but will place stairs even if room is small @@ -2093,13 +2118,20 @@ func _is_valid_interactable_position(world_pos: Vector2, all_doors: Array, all_e return true -func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_rooms: Array, all_doors: Array) -> Array: +func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_rooms: Array, all_doors: Array, other_blocking_doors: Array = []) -> Array: # Find rooms that are reachable from start WITHOUT going through this door # This is used to place keys before KeyDoors + # other_blocking_doors: Array of other doors that should also be treated as blocking (e.g., other key doors) var rooms_before_door = [] var visited = [start_room] var queue = [start_room] + # Create a set of all blocking doors (this door + other blocking doors) + var blocking_doors_set = [door] + for blocking_door in other_blocking_doors: + if blocking_door != door: + blocking_doors_set.append(blocking_door) + while queue.size() > 0: var current = queue.pop_front() @@ -2109,10 +2141,16 @@ func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_room if not rooms_before_door.has(current): rooms_before_door.append(current) - # Check all doors connected to current room (except the blocked door) + # Check all doors connected to current room (except blocking doors) for d in all_doors: - if d == door: - continue # Skip the blocked door + # Skip if this door is in the blocking set + var is_blocking = false + for blocking_door in blocking_doors_set: + if d == blocking_door: + is_blocking = true + break + if is_blocking: + continue # Skip blocking doors var next_room = null if d.room1 == current: @@ -2504,6 +2542,8 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ key_doors_to_create.append(door) # STEP 4: Create KeyDoors with keys placed BEFORE the keydoor + # First pass: collect all key doors to determine blocking relationships + var key_door_data_list = [] for door in key_doors_to_create: # Determine direction var direction = "" @@ -2556,8 +2596,37 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ var door_room2 = door.room2 if "room2" in door else null var blocking_room = door_room2 if door_room2 != null else door_room1 + # Store door data for second pass + key_door_data_list.append({ + "door": door, + "direction": direction, + "door_world_x": door_world_x, + "door_world_y": door_world_y, + "middle_tile_x": middle_tile_x, + "middle_tile_y": middle_tile_y, + "blocking_room": blocking_room + }) + + # Second pass: find rooms before each door, accounting for other key doors + for i in range(key_door_data_list.size()): + var key_door_info = key_door_data_list[i] + var door = key_door_info.door + var direction = key_door_info.direction + var door_world_x = key_door_info.door_world_x + var door_world_y = key_door_info.door_world_y + var middle_tile_x = key_door_info.middle_tile_x + var middle_tile_y = key_door_info.middle_tile_y + var blocking_room = key_door_info.blocking_room + + # Collect other key doors (all key doors except this one) + var other_key_doors = [] + for j in range(key_door_data_list.size()): + if i != j: + other_key_doors.append(key_door_data_list[j].door) + # Find rooms reachable BEFORE this door (for key placement) - var rooms_before_door = _find_rooms_before_door(door, start_room, all_rooms, all_doors) + # Pass other key doors so they're also treated as blocking + var rooms_before_door = _find_rooms_before_door(door, start_room, all_rooms, all_doors, other_key_doors) # Pick a room for the key (must be reachable before the door) var key_room = null diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index fa509b9..99ead7d 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -458,6 +458,16 @@ func rpc_take_damage(amount: float, from_position: Vector2, is_critical: bool = if is_multiplayer_authority(): take_damage(amount, from_position, is_critical, is_burn_damage, apply_burn_debuff) +@rpc("any_peer", "reliable") +func rpc_heal_enemy(amount: float, allow_overheal: bool = false): + # RPC version - only process on server/authority + if is_multiplayer_authority(): + if character_stats: + character_stats.heal(amount, allow_overheal) + # Sync current_health for backwards compatibility + current_health = character_stats.hp + LogManager.log(str(name) + " healed for " + str(amount) + " HP! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY) + 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 diff --git a/src/scripts/enemy_hand.gd b/src/scripts/enemy_hand.gd index 418112c..7ddd1c4 100644 --- a/src/scripts/enemy_hand.gd +++ b/src/scripts/enemy_hand.gd @@ -11,20 +11,27 @@ var players_in_interest: Array[Node] = [] var grabbed_player: Node = null var random_move_dir: Vector2 = Vector2.ZERO var random_move_timer: float = 0.0 +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 TILE_SIZE: int = 16 +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 @onready var anim_player: AnimationPlayer = $AnimationPlayer +@onready var hand_sprite: Sprite2D = $Sprite2D +var _tile_particle_scene: PackedScene = null +var blood_scene: PackedScene = preload("res://scenes/blood_clot.tscn") func _ready() -> void: super._ready() max_health = 25.0 current_health = max_health - move_speed = 55.0 + move_speed = 28.0 # Reduced from 55.0 - much slower damage = SNATCH_DAMAGE exp_reward = 8.0 collision_layer = 2 @@ -72,15 +79,148 @@ func _die() -> void: v.rpc_released_from_enemy_hand.rpc_id(pid) else: v.rpc_released_from_enemy_hand.rpc() + + # Spawn blood clots (similar to player/enemy death) + _spawn_blood_clots() + + # Split hand into 4 pieces that fly in all directions + _spawn_hand_pieces() + super._die() +func _spawn_blood_clots(): + # Spawn blood clots when enemy hand dies (similar to player/enemy death) + if not is_multiplayer_authority(): + return + + if not blood_scene: + return + + var parent = get_parent() + if not parent: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + parent = game_world.get_node_or_null("Entities") + if not parent: + return + + # Spawn 12 blood clots (same as player/enemy) + for i in 12: + var angle = randf_range(0, TAU) + var speed = randf_range(50, 100) + var initial_velocityZ = randf_range(50, 90) + var b = blood_scene.instantiate() as CharacterBody2D + b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2)) + b.global_position = global_position + + # Set initial velocities + var direction = Vector2.from_angle(angle) + b.velocity = direction * speed + b.velocityZ = initial_velocityZ + parent.call_deferred("add_child", b) + +func _spawn_hand_pieces(): + # Split enemy hand into 4 pieces that fly in all directions (like tile particles) + if not is_multiplayer_authority(): + return + + if not hand_sprite or not hand_sprite.texture: + return + + if not _tile_particle_scene: + _tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene + if not _tile_particle_scene: + return + + var parent = get_parent() + if not parent: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + parent = game_world.get_node_or_null("Entities") + if not parent: + return + + # Get current sprite frame + var current_frame = hand_sprite.frame if hand_sprite else 0 + var hframes = hand_sprite.hframes if hand_sprite else 4 + var vframes = hand_sprite.vframes if hand_sprite else 4 + + # Calculate frame position in texture + var frame_x = current_frame % hframes + var frame_y = int(float(current_frame) / float(hframes)) + + # Get texture size and calculate frame size + var tex = hand_sprite.texture + var frame_width = float(tex.get_width()) / float(hframes) + var frame_height = float(tex.get_height()) / float(vframes) + + # Calculate the 4 quadrants of the current frame + var half_width = frame_width / 2.0 + var half_height = frame_height / 2.0 + + var base_x = frame_x * frame_width + var base_y = frame_y * frame_height + + # 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 + ] + + # 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 + ] + + # Spawn 4 pieces + for i in range(4): + var p = _tile_particle_scene.instantiate() as CharacterBody2D + var piece_sprite = p.get_node_or_null("Sprite2D") as Sprite2D + if not piece_sprite: + p.queue_free() + continue + + # Use the hand texture and region + piece_sprite.texture = tex + piece_sprite.region_enabled = true + piece_sprite.region_rect = regions[i] + + # Position at hand location + p.global_position = global_position + + # 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 + 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 + + # Use call_deferred to avoid physics query flush errors + parent.call_deferred("add_child", p) + func _ai_behavior(delta: float) -> void: + # Update grab cooldown timer + if grab_cooldown_timer > 0.0: + grab_cooldown_timer -= delta + if state == HandState.HIDDEN or state == HandState.EMERGING: velocity = Vector2.ZERO return if state == HandState.GRABBING: 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 + # 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) return # IDLE: move toward player if in interest, else random @@ -119,6 +259,13 @@ func _on_emerge_area_body_entered(body: Node2D) -> void: modulate.a = 1.0 if anim_player and anim_player.has_animation("emerge"): anim_player.play("emerge") + + # Spawn tile particles when emerging + _spawn_emerge_tile_particles() + + # Sync visibility to all clients + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + _sync_hand_emerged.rpc() func _on_animation_finished(anim_name: StringName) -> void: @@ -152,6 +299,8 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void: return if grabbed_player != null: return + if grab_cooldown_timer > 0.0: + return # Still on cooldown from previous grab if not is_multiplayer_authority(): return @@ -174,12 +323,25 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void: get_tree().create_timer(SNATCH_DURATION).timeout.connect(_finish_snatch) +@rpc("authority", "reliable") +func _sync_hand_emerged(): + # Sync hand emergence visibility to clients + if is_multiplayer_authority(): + return # Authority already handled it locally + + if state == HandState.HIDDEN: + state = HandState.EMERGING + modulate.a = 1.0 + if anim_player and anim_player.has_animation("emerge"): + anim_player.play("emerge") + func _finish_snatch() -> void: if not is_instance_valid(self): return var victim = grabbed_player grabbed_player = null state = HandState.IDLE + 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): @@ -208,6 +370,78 @@ func _finish_snatch() -> void: victim.rpc_released_from_enemy_hand.rpc() +func _spawn_emerge_tile_particles(): + # Spawn tile particles from the tile the hand is emerging from (similar to bomb explosion) + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world: + return + + var layer = game_world.get_node_or_null("Environment/DungeonLayer0") + if not layer or not layer is TileMapLayer: + return + + if not _tile_particle_scene: + _tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene + if not _tile_particle_scene: + return + + var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D + if not tex: + return + + var center = global_position + var layer_pos = center - layer.global_position + var center_cell = layer.local_to_map(layer_pos) + + var parent = get_parent() + if not parent: + parent = game_world.get_node_or_null("Entities") + if not parent: + return + + # 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 + + 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 regions = [ + Rect2(bx, by, h, h), + Rect2(bx + h, by, h, h), + Rect2(bx, by + h, h, h), + Rect2(bx + h, by + h, h, h) + ] + + # Spawn 2-3 particles from the tile, flying outward in random directions + var num_particles = randi_range(2, 3) + for i in range(num_particles): + var p = _tile_particle_scene.instantiate() as CharacterBody2D + var spr = p.get_node_or_null("Sprite2D") as Sprite2D + if not spr: + p.queue_free() + continue + spr.texture = tex + spr.region_enabled = true + # Randomly pick one of the 4 tile quadrants + var region_idx = randi() % 4 + spr.region_rect = regions[region_idx] + p.global_position = world + # 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 + 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 + # Use call_deferred to avoid physics query flush errors + parent.call_deferred("add_child", p) + func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0, frame: int = 0, anim: String = "", frame_num: int = 0, state_value: int = -1) -> void: super._sync_position(pos, vel, z_pos, dir, frame, anim, frame_num, state_value) # Client: keep AnimationPlayer in sync with state if we add state sync later diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index f0dfd8d..2703b69 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -25,8 +25,14 @@ enum HumanoidType { @onready var sprite_eyelashes = $Sprite2DEyeLashes @onready var sprite_addons = $Sprite2DAddons @onready var sprite_headgear = $Sprite2DHeadgear +@onready var sprite_shield = $Sprite2DShield +@onready var sprite_shield_holding = $Sprite2DShieldHolding @onready var sprite_weapon = $Sprite2DWeapon +# Incantation (spell casting visual) +@onready var incantation_sprite = $Incantation/IncantationSprite +@onready var animation_incantation = $Incantation/AnimationIncantation + # Attack system var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") var can_attack: bool = true @@ -37,8 +43,38 @@ 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 + +# 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 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 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 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 + # AI state -enum AIState {IDLE, WANDERING, NOTICED, CHASING, ATTACKING, GROUPING} +enum AIState {IDLE, WANDERING, NOTICED, CHASING, ATTACKING, GROUPING, BOW_CHARGING, THROWING_BOMB, CASTING_SPELL, LIFTING} var ai_state: AIState = AIState.IDLE var state_timer: float = 0.0 var group_target: Node = null # Other humanoid to group with @@ -60,6 +96,14 @@ var lost_target_duration: float = 1.0 # Time before forgetting player # Sound effects @onready var sfx_die = $SfxDie @onready var sfx_alert_found_player = $SfxAlertFoundPlayer +@onready var sfx_activate_shield = $SfxActivateShield +@onready var sfx_block_with_shield = $SfxBlockWithShield +@onready var sfx_bow_shoot = $SfxBowShoot +@onready var sfx_bow_without_arrow = $SfxBowWithoutArrow +@onready var sfx_buckle_bow = $SfxBuckleBow +@onready var sfx_spell_charge = $SfxSpellCharge +@onready var sfx_throw = $SfxThrow +@onready var sfx_lift = $SfxLift # Animation system (same as player) const ANIMATIONS = { @@ -216,6 +260,30 @@ func _ready(): # Undead types (e.g. skeleton) take damage from healing spell is_undead = (humanoid_type == HumanoidType.SKELETON) + # Assign loadout (bow, bomb, spell, shield, lift/throw) — variety, not experts + _assign_loadout() + + # Adjust headgear for spell casters (must be after loadout assignment) + if spell_type != "": + # Spell caster: remove non-magician headgear and replace with magician headgear or none + if sprite_headgear and sprite_headgear.texture: + # Check if current headgear is NOT a magician headgear + var current_path = sprite_headgear.texture.resource_path + var is_magician_headgear = "Basic Mage" in current_path or current_path.ends_with("Headband.png") + if not is_magician_headgear: + # Remove non-magician headgear + sprite_headgear.texture = null + # 50% chance to add magician headgear + if appearance_rng.randf() < 0.5: + _load_magician_headgear() + else: + # No headgear currently - 50% chance to add magician headgear + if appearance_rng.randf() < 0.5: + _load_magician_headgear() + + # Update shield visibility based on loadout + _update_shield_visibility() + # Start in idle state ai_state = AIState.IDLE state_timer = 2.0 @@ -547,6 +615,8 @@ func _load_random_equipment(): _load_random_gloves() # Random headgear (Layer 6 - Headgears) + # Note: Spell casters will have their headgear adjusted after loadout assignment + # For now, load random headgear (will be replaced if spell caster) if appearance_rng.randf() < 0.5: # 50% chance to have headgear _load_random_headgear() @@ -649,6 +719,28 @@ func _load_random_gloves(): sprite_armour.hframes = 35 sprite_armour.vframes = 8 +func _load_magician_headgear(): + # Load magician headgear for spell casters (Hat, Wizard's Hat, Mage Hat, etc.) + if not sprite_headgear: + return + + # Available magician headgears (Basic Mage category) + var magician_headgears = [ + "EsperHatBlue.png", + "HighMageHatCyan.png", + "MageHatRed.png", + "SorcererHoodCyan.png" + ] + + var selected_headgear = magician_headgears[appearance_rng.randi() % magician_headgears.size()] + var headgear_path = "res://assets/gfx/Puny-Characters/Layer 6 - Headgears/Basic Mage/" + selected_headgear + + var headgear_texture = load(headgear_path) + if headgear_texture: + sprite_headgear.texture = headgear_texture + sprite_headgear.hframes = 35 + sprite_headgear.vframes = 8 + func _load_random_headgear(): if not sprite_headgear: return @@ -856,6 +948,68 @@ func _setup_stats(): if collision_shape and collision_shape.shape: collision_shape.shape.radius = aggro_range +func _assign_loadout(): + # Assign bow, bomb, spell, shield, lift/throw — variety, not everyone has everything. Use RNG for consistency. + if not appearance_rng: + return + # Bow: ~22% get bow, 3–5 arrows + if appearance_rng.randf() < 0.22: + has_bow = true + arrows_left = appearance_rng.randi_range(3, 5) + # Bomb: ~14% get 1–2 bombs + if appearance_rng.randf() < 0.14: + has_bomb = true + bombs_left = appearance_rng.randi_range(1, 2) + # Spell: ~20% — flames 12%, frost 6%, healing 2% + # Note: Enemies use spell_type strings (not tome items like players) + # "flames" = Tome of Flames spell, "frost" = Tome of Frostspike spell, "healing" = Tome of Healing spell + var spell_roll = appearance_rng.randf() + if spell_roll < 0.12: + spell_type = "flames" + LogManager.log(str(name) + " assigned flames spell (tome spell enemy)", LogManager.CATEGORY_ENEMY) + elif spell_roll < 0.18: + spell_type = "frost" + LogManager.log(str(name) + " assigned frost spell (tome spell enemy)", LogManager.CATEGORY_ENEMY) + elif spell_roll < 0.20: + spell_type = "healing" + LogManager.log(str(name) + " assigned healing spell (tome spell enemy)", LogManager.CATEGORY_ENEMY) + # Shield: ~24% get shield, block chance 0.12–0.22 + if appearance_rng.randf() < 0.24: + has_shield = true + shield_block_chance = 0.12 + appearance_rng.randf() * 0.10 + # Lift/throw: ~12% can grab and throw liftable objects when aggro + if appearance_rng.randf() < 0.12: + can_lift_throw = true + +func _add_aim_error(dir: Vector2, max_degrees: float) -> Vector2: + if dir.length_squared() < 0.001: + return dir + var deg = randf_range(-max_degrees, max_degrees) + return dir.rotated(deg_to_rad(deg)) + +func _get_nearby_liftable() -> Node: + var gw = get_tree().get_first_node_in_group("game_world") + if not gw: + return null + var entities = gw.get_node_or_null("Entities") + if not entities: + return null + const MAX_DIST: float = 52.0 + var best: Node = null + var best_d: float = MAX_DIST + for c in entities.get_children(): + if not c.has_method("can_be_grabbed") or not c.has_method("can_be_lifted"): + continue + if not c.can_be_grabbed() or not c.can_be_lifted(): + continue + if "is_broken" in c and c.is_broken: + continue + var d = global_position.distance_to(c.global_position) + if d < best_d: + best_d = d + best = c + return best + func _physics_process(delta): # Always update animation (even when dead, for death animation) _update_animation(delta) @@ -881,9 +1035,29 @@ func _physics_process(delta): if attack_timer > 0: attack_timer -= delta if attack_timer <= 0: - # Attack cooldown finished - reset attack flags can_attack = true is_attacking = false + if spell_cooldown_timer > 0: + spell_cooldown_timer -= delta + if bomb_cooldown_timer > 0: + bomb_cooldown_timer -= delta + if lift_throw_cooldown_timer > 0: + lift_throw_cooldown_timer -= delta + if ranged_decision_timer > 0: + ranged_decision_timer -= delta + if shield_block_timer > 0: + shield_block_timer -= delta + if shield_block_timer <= 0: + is_blocking = false + _update_shield_visibility() + + # Update bow charge pulse timer when charging bow + if ai_state == AIState.BOW_CHARGING and is_charging_attack: + bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged + _apply_bow_charge_tint() + elif ai_state != AIState.BOW_CHARGING: + # Clear bow charge tint when not charging + _clear_bow_charge_tint() # Handle knockback if is_knocked_back: @@ -943,6 +1117,14 @@ func _ai_behavior(delta): _attacking_behavior(delta) AIState.GROUPING: _grouping_behavior(delta) + AIState.BOW_CHARGING: + _bow_charging_behavior(delta) + AIState.THROWING_BOMB: + _throwing_bomb_behavior(delta) + AIState.CASTING_SPELL: + _casting_spell_behavior(delta) + AIState.LIFTING: + _lifting_behavior(delta) # Update lost target timer if target_player: @@ -1030,7 +1212,7 @@ func _noticed_behavior(_delta): ai_state = AIState.CHASING state_timer = 5.0 -func _chasing_behavior(_delta): +func _chasing_behavior(delta_arg): if not target_player or not is_instance_valid(target_player) or target_player.is_dead: _hide_alert_indicators() ai_state = AIState.IDLE @@ -1039,61 +1221,160 @@ func _chasing_behavior(_delta): var dist = global_position.distance_to(target_player.global_position) - # Check if player left aggro range (handled by Area2D and timer in _ai_behavior) - # But still check if player is in vision for immediate reaction if dist > aggro_range: - # Player left aggro range - timer will handle forgetting - # Don't immediately switch state, let timer handle it pass - # Check if player is still in vision if not _is_player_in_vision(target_player): - # Lost sight of player - go back to patrolling ai_state = AIState.WANDERING state_timer = 2.0 return - # Calculate direction to player var to_player = (target_player.global_position - global_position).normalized() - # Attack if close enough (start charging attack) + # --- Shield blocking: raise shield when player is close and attacking --- + 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 + # 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 + # Also raise shield if player is very close (within attack range) + if dist < 50.0: + player_is_attacking = true + + # Raise shield if player is attacking or very close + if player_is_attacking and not is_blocking: + is_blocking = true + shield_block_timer = shield_block_duration + _update_shield_visibility() + # Play shield activation sound + if sfx_activate_shield: + sfx_activate_shield.play() + elif not player_is_attacking and is_blocking and shield_block_timer <= 0: + # Lower shield if player is not attacking and timer expired + is_blocking = false + _update_shield_visibility() + + # --- Lift/throw: try grab nearby liftable when aggro --- + if can_lift_throw and lift_throw_cooldown_timer <= 0: + var liftable = _get_nearby_liftable() + if liftable: + ai_state = AIState.LIFTING + state_timer = 2.0 + velocity = Vector2.ZERO + current_direction = _get_direction_from_vector((liftable.global_position - global_position).normalized()) + set_meta("_lift_target", liftable) + return + + # --- Ranged (bow / bomb / spell): roll periodically, poor aim / mistakes --- + ranged_decision_timer -= delta_arg + if ranged_decision_timer <= 0: + ranged_decision_timer = 0.4 + # 10% mistake: skip ranged even when we would use it + if randf() < 0.10: + pass + elif has_bow and arrows_left > 0 and can_attack and dist >= 72 and dist <= 190: + if randf() < 0.18: + ai_state = AIState.BOW_CHARGING + is_charging_attack = true + 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 + 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 + velocity = Vector2.ZERO + current_direction = _get_direction_from_vector(to_player) + # Create bomb object above enemy's head + _create_held_bomb_object() + return + elif spell_type != "" and spell_cooldown_timer <= 0 and dist >= 56 and dist <= 145: + if randf() < 0.10: + ai_state = AIState.CASTING_SPELL + state_timer = 1.4 + velocity = Vector2.ZERO + current_direction = _get_direction_from_vector(to_player) + return + + # --- Melee: close enough to attack --- if dist < 45.0 and can_attack and not is_charging_attack: + # Lower shield when attacking (can't block while attacking) + if is_blocking: + is_blocking = false + shield_block_timer = 0.0 + _update_shield_visibility() ai_state = AIState.ATTACKING is_charging_attack = true attack_charge_time = base_attack_charge_time - velocity = Vector2.ZERO # Stop moving - current_direction = _get_direction_from_vector(to_player) # Face player + velocity = Vector2.ZERO + current_direction = _get_direction_from_vector(to_player) return - # Chase player (get close enough to attack) - var desired_distance = 45.0 # Stop this far from player (attack range) + var desired_distance = 45.0 + + # Apply speed multiplier if blocking + var speed_mult = 1.0 + if is_blocking: + speed_mult = shield_block_speed_multiplier + if dist > desired_distance: # Still too far - chase player - velocity = to_player * move_speed * 0.8 # Reduce chase speed to 80% (was 100%) + 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 if can_attack: - # Can attack - stop and wait for attack opportunity - velocity = Vector2.ZERO + # Can attack - stop and wait for attack opportunity (unless blocking, then move slowly) + if is_blocking: + # When blocking, move slowly to maintain position + velocity = to_player * move_speed * 0.2 * speed_mult + else: + velocity = Vector2.ZERO else: # Attack on cooldown - keep moving slowly to maintain position # 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 + velocity = -to_player * move_speed * 0.3 * speed_mult else: - # Good distance - just face player - velocity = Vector2.ZERO + # Good distance - just face player (or move slowly if blocking) + if is_blocking: + velocity = to_player * move_speed * 0.2 * speed_mult + else: + velocity = Vector2.ZERO current_direction = _get_direction_from_vector(to_player) - # Set animation based on movement - if velocity.length() > 0.1: - if current_animation != "RUN" and current_animation != "SWORD" and current_animation != "DAMAGE": - _set_animation("RUN") + # Set animation based on movement and whether holding something + var is_holding_object = (held_bomb_object != null and is_instance_valid(held_bomb_object)) + + # When blocking, use hold animations (shield is held up) + if is_blocking: + if velocity.length() > 0.1: + if current_animation != "RUN_HOLD" and current_animation != "SWORD" and current_animation != "DAMAGE" and current_animation != "THROW": + _set_animation("RUN_HOLD") + else: + if current_animation != "IDLE_HOLD" and current_animation != "SWORD" and current_animation != "DAMAGE" and current_animation != "THROW": + _set_animation("IDLE_HOLD") + elif is_holding_object: + # Holding bomb or object - use hold animations + if velocity.length() > 0.1: + if current_animation != "RUN_HOLD" and current_animation != "SWORD" and current_animation != "DAMAGE" and current_animation != "THROW": + _set_animation("RUN_HOLD") + else: + if current_animation != "IDLE_HOLD" and current_animation != "SWORD" and current_animation != "DAMAGE" and current_animation != "THROW": + _set_animation("IDLE_HOLD") else: - if current_animation != "IDLE" and current_animation != "SWORD" and current_animation != "DAMAGE": - _set_animation("IDLE") + # Not holding anything - use normal animations + if velocity.length() > 0.1: + if current_animation != "RUN" and current_animation != "SWORD" and current_animation != "DAMAGE": + _set_animation("RUN") + else: + if current_animation != "IDLE" and current_animation != "SWORD" and current_animation != "DAMAGE": + _set_animation("IDLE") # Give up chasing after timer (or if player leaves vision) if state_timer <= 0: @@ -1167,6 +1448,287 @@ func _grouping_behavior(_delta): ai_state = AIState.IDLE state_timer = 2.0 +func _get_attack_direction_vector() -> Vector2: + match current_direction: + Direction.RIGHT: return Vector2.RIGHT + Direction.DOWN_RIGHT: return Vector2(1, 1).normalized() + Direction.DOWN: return Vector2.DOWN + Direction.DOWN_LEFT: return Vector2(-1, 1).normalized() + Direction.LEFT: return Vector2.LEFT + Direction.UP_LEFT: return Vector2(-1, -1).normalized() + Direction.UP: return Vector2.UP + Direction.UP_RIGHT: return Vector2(1, -1).normalized() + return Vector2.DOWN + +func _bow_charging_behavior(delta): + velocity = Vector2.ZERO + if not target_player or not is_instance_valid(target_player) or target_player.is_dead: + ai_state = AIState.CHASING + state_timer = 2.0 + return + var to_player = (target_player.global_position - global_position).normalized() + current_direction = _get_direction_from_vector(to_player) + if is_charging_attack: + attack_charge_time -= delta + if attack_charge_time <= 0: + is_charging_attack = false + _perform_bow_attack() + else: + if current_animation != "BOW" and current_animation != "DAMAGE": + _set_animation("BOW") + return + ai_state = AIState.CHASING + state_timer = 2.0 + +func _perform_bow_attack(): + if not is_multiplayer_authority(): + return + if arrows_left <= 0 or not attack_arrow_scene: + ai_state = AIState.CHASING + _clear_bow_charge_tint() + return + + # 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 + + var base_dir = _get_attack_direction_vector() + var charge_pct = 0.65 + randf() * 0.25 + + # Fire arrows with slight spread + 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 + dir = dir.rotated(spread_angle) + # Add additional aim error + dir = _add_aim_error(dir, randf_range(16.0, 28.0)) + + # Slight delay between arrows for visual effect + if i > 0: + await get_tree().create_timer(0.08 * i).timeout + + var arr = attack_arrow_scene.instantiate() + var par = get_parent() + if par: + par.add_child(arr) + arr.shoot(dir, global_position, self, charge_pct) + + arrows_left -= num_arrows + can_attack = false + attack_timer = attack_cooldown * 1.4 + state_timer = attack_cooldown * 1.2 + _set_animation("BOW") + _clear_bow_charge_tint() + ai_state = AIState.CHASING + +func _throwing_bomb_behavior(delta): + state_timer -= delta + velocity = Vector2.ZERO + if not target_player or not is_instance_valid(target_player) or target_player.is_dead: + # Clean up held bomb if target is lost + _cleanup_held_bomb() + ai_state = AIState.CHASING + state_timer = 2.0 + return + + # 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 + 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 + + # Face the player while holding bomb + current_direction = _get_direction_from_vector((target_player.global_position - global_position).normalized()) + + # First phase: Hold bomb above head (show it for at least 1.0 second) + if state_timer > 1.0: + # Use IDLE_HOLD animation while holding bomb (enemy is stationary) + if current_animation != "IDLE_HOLD" and current_animation != "DAMAGE": + _set_animation("IDLE_HOLD") + return + + # Second phase: Throw the bomb (after 1.0 second of holding) + if state_timer <= 1.0 and state_timer > 0.0: + # Use THROW animation when actually throwing + if current_animation != "THROW" and current_animation != "DAMAGE": + _set_animation("THROW") + return + + # Third phase: Actually throw the bomb (when timer reaches 0) + if state_timer <= 0.0 and bombs_left > 0 and is_multiplayer_authority(): + _throw_held_bomb() + + # Return to chasing after throw + ai_state = AIState.CHASING + state_timer = 2.0 + +func _create_held_bomb_object(): + # Create a bomb object that will be held above enemy's head before throwing + if not is_multiplayer_authority(): + return + + if not interactable_object_scene: + push_error("ERROR: Could not load interactable_object scene!") + return + + # Clean up any existing held bomb + _cleanup_held_bomb() + + # Spawn bomb object above enemy's head + var entities_node = get_parent() + if not entities_node: + entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities") + if not entities_node: + push_error("ERROR: Could not find Entities node!") + return + + 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 + + # Set multiplayer authority + if multiplayer.has_multiplayer_peer(): + bomb_obj.set_multiplayer_authority(get_multiplayer_authority()) + + entities_node.add_child(bomb_obj) + + # Setup as bomb object + bomb_obj.setup_bomb() + + # Disable collision so it doesn't interfere + 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 + + # Make sure sprite is visible + if bomb_obj.has_node("Sprite2D"): + bomb_obj.get_node("Sprite2D").visible = true + + held_bomb_object = bomb_obj + + print(name, " created held bomb object above head") + +func _throw_held_bomb(): + # Throw the bomb that's been held above enemy's head + if not held_bomb_object or not is_instance_valid(held_bomb_object): + # Fallback: create bomb directly if held bomb is missing + if bombs_left > 0 and attack_bomb_scene: + bombs_left -= 1 + bomb_cooldown_timer = 8.0 + var fallback_offset = Vector2(randf_range(-22, 22), randf_range(-22, 22)) + var fallback_target_pos = target_player.global_position + fallback_offset + var fallback_throw_dir = (fallback_target_pos - global_position).normalized() + var fallback_throw_force = fallback_throw_dir * randf_range(180, 260) + var bomb = attack_bomb_scene.instantiate() + var par = get_parent() + if par: + par.add_child(bomb) + bomb.global_position = global_position + Vector2(0, -20) # From above head + bomb.setup(bomb.global_position, self, fallback_throw_force, true) + return + + if bombs_left <= 0: + _cleanup_held_bomb() + return + + bombs_left -= 1 + bomb_cooldown_timer = 8.0 + + # Calculate throw direction and force + var throw_offset = Vector2(randf_range(-22, 22), randf_range(-22, 22)) + var throw_target_pos = target_player.global_position + throw_offset + var throw_dir = (throw_target_pos - global_position).normalized() + var throw_force = throw_dir * randf_range(180, 260) + + # Convert held bomb object to thrown bomb projectile + held_bomb_object._convert_to_bomb_projectile(self, throw_force) + + # Clear reference (object will be freed by _convert_to_bomb_projectile) + held_bomb_object = null + + print(name, " threw bomb from above head!") + +func _cleanup_held_bomb(): + # Clean up held bomb object if it exists + if held_bomb_object and is_instance_valid(held_bomb_object): + held_bomb_object.queue_free() + held_bomb_object = null + +func _casting_spell_behavior(delta): + state_timer -= delta + velocity = Vector2.ZERO + if not target_player or not is_instance_valid(target_player) or target_player.is_dead: + ai_state = AIState.CHASING + return + if state_timer > 0.8: + current_direction = _get_direction_from_vector((target_player.global_position - global_position).normalized()) + if current_animation != "CONJURE" and current_animation != "DAMAGE": + _set_animation("CONJURE") + return + if state_timer <= 0.8 and is_multiplayer_authority(): + spell_cooldown_timer = 7.0 + var gw = get_tree().get_first_node_in_group("game_world") + var base_target = target_player.global_position + var offset = Vector2(randf_range(-20, 20), randf_range(-20, 20)) + var desired = base_target + offset + var valid = Vector2.ZERO + if gw and gw.has_method("_get_valid_spell_target_for_enemy"): + valid = gw._get_valid_spell_target_for_enemy(global_position, desired, get_rid()) + if valid == Vector2.ZERO: + valid = global_position + (desired - global_position).normalized() * 80.0 + var dmg = damage * 0.8 + character_stats.baseStats.int * 0.3 if character_stats else damage + if spell_type == "flames" and flame_spell_scene: + var s = flame_spell_scene.instantiate() + var par = get_parent() + if par: + par.add_child(s) + s.setup(valid, self, dmg) + elif spell_type == "frost" and frostspike_spell_scene: + var s = frostspike_spell_scene.instantiate() + var par = get_parent() + if par: + par.add_child(s) + s.setup(valid, self, dmg, false) + elif spell_type == "healing": + pass + ai_state = AIState.CHASING + state_timer = 2.0 + +func _lifting_behavior(delta): + state_timer -= delta + velocity = Vector2.ZERO + var lift_target = get_meta("_lift_target") if has_meta("_lift_target") else null + if not lift_target or not is_instance_valid(lift_target): + remove_meta("_lift_target") + ai_state = AIState.CHASING + lift_throw_cooldown_timer = 6.0 + return + if state_timer > 1.2: + current_direction = _get_direction_from_vector((lift_target.global_position - global_position).normalized()) + if current_animation != "LIFT" and current_animation != "DAMAGE": + _set_animation("LIFT") + return + if state_timer <= 1.2 and is_multiplayer_authority(): + remove_meta("_lift_target") + lift_throw_cooldown_timer = 10.0 + if lift_target.has_method("can_be_grabbed") and lift_target.can_be_grabbed() and lift_target.has_method("on_grabbed"): + lift_target.on_grabbed(self) + lift_target.global_position = global_position + Vector2(0, -8) + var to_player = (target_player.global_position - global_position).normalized() if target_player else Vector2.DOWN + to_player = _add_aim_error(to_player, 22.0) + var force = to_player * randf_range(180, 280) + if lift_target.has_method("on_thrown"): + lift_target.on_thrown(self, force) + ai_state = AIState.CHASING + state_timer = 2.0 + func _find_group_target(): # Find nearby humanoid enemies to group with var humanoids = get_tree().get_nodes_in_group("enemy") @@ -1369,6 +1931,10 @@ func _update_animation(delta): sprite_addons.frame = frame_index if sprite_headgear: sprite_headgear.frame = frame_index + if sprite_shield: + sprite_shield.frame = frame_index + if sprite_shield_holding: + sprite_shield_holding.frame = frame_index if sprite_weapon: sprite_weapon.frame = frame_index @@ -1443,12 +2009,132 @@ func _update_client_visuals(): var y_offset = - position_z * 0.5 for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, - sprite_headgear, sprite_weapon]: + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon]: if sprite_layer: sprite_layer.position.y = y_offset # Animation is updated in _update_animation which is called every frame +func _apply_bow_charge_tint(): + # Apply pulsing white tint to all sprite layers when charging bow (similar to player) + if ai_state != AIState.BOW_CHARGING or not is_charging_attack: + return + + var sprite_layers = [ + {"sprite": sprite_body, "name": "body"}, + {"sprite": sprite_boots, "name": "boots"}, + {"sprite": sprite_armour, "name": "armour"}, + {"sprite": sprite_facial_hair, "name": "facial_hair"}, + {"sprite": sprite_hair, "name": "hair"}, + {"sprite": sprite_eyes, "name": "eyes"}, + {"sprite": sprite_eyelashes, "name": "eyelashes"}, + {"sprite": sprite_addons, "name": "addons"}, + {"sprite": sprite_headgear, "name": "headgear"}, + {"sprite": sprite_shield, "name": "shield"}, + {"sprite": sprite_shield_holding, "name": "shield_holding"}, + {"sprite": sprite_weapon, "name": "weapon"} + ] + + # 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 + + for sprite_data in sprite_layers: + var sprite_layer = sprite_data.sprite + var sprite_name = sprite_data.name + + if not sprite_layer or not is_instance_valid(sprite_layer): + continue + + if sprite_layer.material and sprite_layer.material is ShaderMaterial: + var shader_material = sprite_layer.material as ShaderMaterial + + # Store original tint if not already stored + var tint_key = str(get_instance_id()) + "_bow_" + sprite_name + if not tint_key in original_sprite_tints: + var original_tint_param = shader_material.get_shader_parameter("tint") + if original_tint_param is Vector4: + original_sprite_tints[tint_key] = Color(original_tint_param.x, original_tint_param.y, original_tint_param.z, original_tint_param.w) + elif original_tint_param is Color: + original_sprite_tints[tint_key] = original_tint_param + else: + original_sprite_tints[tint_key] = Color.WHITE + + # Get original tint + var original_tint = original_sprite_tints.get(tint_key, Color.WHITE) + + # Calculate fully charged tint (original * bow_charge_tint - white tint) + var full_charged_tint = Color( + original_tint.r * bow_charge_tint.r, + original_tint.g * bow_charge_tint.g, + original_tint.b * bow_charge_tint.b, + original_tint.a * bow_charge_tint.a + ) + + # 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 + shader_material.set_shader_parameter("tint", Vector4(current_tint.r, current_tint.g, current_tint.b, current_tint.a)) + +func _clear_bow_charge_tint(): + # Clear bow charge tint from all sprite layers + var sprite_layers = [ + {"sprite": sprite_body, "name": "body"}, + {"sprite": sprite_boots, "name": "boots"}, + {"sprite": sprite_armour, "name": "armour"}, + {"sprite": sprite_facial_hair, "name": "facial_hair"}, + {"sprite": sprite_hair, "name": "hair"}, + {"sprite": sprite_eyes, "name": "eyes"}, + {"sprite": sprite_eyelashes, "name": "eyelashes"}, + {"sprite": sprite_addons, "name": "addons"}, + {"sprite": sprite_headgear, "name": "headgear"}, + {"sprite": sprite_shield, "name": "shield"}, + {"sprite": sprite_shield_holding, "name": "shield_holding"}, + {"sprite": sprite_weapon, "name": "weapon"} + ] + + var keys_to_remove = [] + + for sprite_data in sprite_layers: + var sprite_layer = sprite_data.sprite + var sprite_name = sprite_data.name + + if not sprite_layer or not is_instance_valid(sprite_layer): + continue + + if sprite_layer.material and sprite_layer.material is ShaderMaterial: + var shader_material = sprite_layer.material as ShaderMaterial + var tint_key = str(get_instance_id()) + "_bow_" + sprite_name + + # Restore original tint if we stored it + if tint_key in original_sprite_tints: + var original_tint = original_sprite_tints[tint_key] + shader_material.set_shader_parameter("tint", Vector4(original_tint.r, original_tint.g, original_tint.b, original_tint.a)) + keys_to_remove.append(tint_key) + + # Clear stored tints + for key in keys_to_remove: + original_sprite_tints.erase(key) + + # Reset pulse timer + bow_charge_tint_pulse_time = 0.0 + +func _update_shield_visibility() -> void: + # Update shield sprite visibility based on whether enemy has a shield and is blocking + if not sprite_shield or not sprite_shield_holding: + return + + if not has_shield: + sprite_shield.visible = false + sprite_shield_holding.visible = false + return + + # Show holding sprite when actively blocking, regular sprite otherwise + if is_blocking: + sprite_shield.visible = false + sprite_shield_holding.visible = true + else: + sprite_shield.visible = true + sprite_shield_holding.visible = false + func _flash_damage(): # Flash all sprite layers red (override base class which uses single sprite) # But don't flash if dead or about to die - just play die animation @@ -1457,26 +2143,48 @@ func _flash_damage(): for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, - sprite_headgear, sprite_weapon]: + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon]: if sprite_layer: var tween = create_tween() tween.tween_property(sprite_layer, "modulate", Color.RED, 0.1) tween.tween_property(sprite_layer, "modulate", Color.WHITE, 0.1) +func take_damage(amount: float, from_position: Vector2, is_critical: bool = false, is_burn_damage: bool = false, apply_burn_debuff: bool = false): + if has_shield and shield_block_chance > 0 and from_position != Vector2.ZERO and not is_burn_damage: + var to_attacker = (from_position - global_position).normalized() + var facing = _get_attack_direction_vector() + # Check if attack is coming from the direction we're facing (blocking direction) + if to_attacker.dot(facing) > 0.5 and randf() < shield_block_chance: + # Successfully blocked - reduce damage + amount = amount * 0.5 + # Raise shield if not already blocking + if not is_blocking: + is_blocking = true + shield_block_timer = shield_block_duration + _update_shield_visibility() + if sfx_activate_shield: + sfx_activate_shield.play() + # Play block sound + if sfx_block_with_shield: + sfx_block_with_shield.play() + # Face the attacker + current_direction = _get_direction_from_vector(to_attacker) as Direction + else: + # 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 + _update_shield_visibility() + if sfx_activate_shield: + sfx_activate_shield.play() + super.take_damage(amount, from_position, is_critical, is_burn_damage, apply_burn_debuff) + func _on_take_damage(attacker_position: Vector2 = Vector2.ZERO): - # CRITICAL: Don't play damage animation if already dead - # This prevents damage sync from overriding death animation on clients if is_dead: return - - # Override to play damage animation and face attacker (same as player) _set_animation("DAMAGE") - - # Face the attacker (if attacker position is provided) if attacker_position != Vector2.ZERO: - # Calculate direction FROM attacker TO victim var direction_from_attacker = (global_position - attacker_position).normalized() - # Face the attacker (opposite of direction from attacker) current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction func _play_death_animation(): @@ -1520,7 +2228,7 @@ func _play_death_animation(): fade_tween.set_parallel(true) for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, - sprite_headgear, sprite_weapon, shadow]: + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 0.5) diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 4f3bda5..442d22e 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -5,6 +5,9 @@ extends Node2D @onready var player_manager = $PlayerManager @onready var camera = $Camera2D @onready var network_manager = $"/root/NetworkManager" +@onready var bg_music: AudioStreamPlayer = $BgMusic +@onready var canvas_modulate: CanvasModulate = $CanvasModulate +@onready var environment: Node2D = $Environment # Screenshake system var screenshake_offset: Vector2 = Vector2.ZERO @@ -47,6 +50,10 @@ 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) + +# 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 @@ -113,6 +120,12 @@ var dungeon_blob_metadata: Dictionary = {} # Static metadata: seed, level, map_ # 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 + +# 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 respawn_all_check_running: bool = false # Track broken interactable objects (object_index -> true) for syncing to new clients var broken_objects: Dictionary = {} # object_index -> true @@ -1141,56 +1154,77 @@ func _show_loot_floating_text(player: Node, text: String, color: Color, item_tex func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool, is_revive: bool = false, is_damage_to_enemy: bool = false): var target: Node = null - if is_damage_to_enemy: + + # Find target - check both players and enemies + for p in get_tree().get_nodes_in_group("player"): + if p.name == target_name and is_instance_valid(p): + target = p + break + + if not target: for e in get_tree().get_nodes_in_group("enemy"): if e.name == target_name and is_instance_valid(e): target = e break - else: - for p in get_tree().get_nodes_in_group("player"): - if p.name == target_name and is_instance_valid(p): - target = p - break + if not target: return + if is_damage_to_enemy: - # Damage already applied by caster; just spawn effect for other clients - var entities = get_node_or_null("Entities") - var parent = entities if entities else target.get_parent() + # Damage already applied by caster; just spawn effect on target (enemy) + var parent = target.get_parent() if target.get_parent() else get_node_or_null("Entities") if parent: var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene if eff_scene: var eff = eff_scene.instantiate() parent.add_child(eff) - eff.global_position = target.global_position + var pos = target.global_position + eff.global_position = pos if eff.has_method("setup"): eff.setup(target) + eff.global_position = pos return + var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() + if is_revive: - if me == tid and target.has_method("_revive_from_heal"): - target._revive_from_heal(display_amount) - elif tid != 0 and target.has_method("_revive_from_heal"): - target._revive_from_heal.rpc_id(tid, display_amount) + # Revive only works for players + if target.is_in_group("player"): + if me == tid and target.has_method("_revive_from_heal"): + target._revive_from_heal(display_amount) + elif tid != 0 and target.has_method("_revive_from_heal"): + target._revive_from_heal.rpc_id(tid, display_amount) else: - if me == tid and target.has_method("heal") and amount_to_apply > 0: - target.heal(amount_to_apply, allow_overheal) + # Heal players or enemies + if amount_to_apply > 0: + if target.is_in_group("player") and target.has_method("heal"): + if me == tid: + target.heal(amount_to_apply, allow_overheal) + elif target.is_in_group("enemy") and target.character_stats: + # Enemy healing - use character_stats.heal() directly + if me == tid: + target.character_stats.heal(amount_to_apply, allow_overheal) + # Sync current_health for backwards compatibility + target.current_health = target.character_stats.hp + elif tid != 0: + # Sync enemy healing via RPC + if target.has_method("rpc_heal_enemy"): + target.rpc_heal_enemy.rpc_id(tid, amount_to_apply, allow_overheal) # When revive, target's authority already spawned effect+text in _revive_from_heal; skip to avoid double spawn var skip_spawn = is_revive and me == tid if not skip_spawn: - var entities = get_node_or_null("Entities") - var parent = entities if entities else target.get_parent() - if not parent: - pass - else: + var parent = target.get_parent() if target.get_parent() else get_node_or_null("Entities") + if parent: var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene if eff_scene: var eff = eff_scene.instantiate() parent.add_child(eff) - eff.global_position = target.global_position + var pos = target.global_position + eff.global_position = pos if eff.has_method("setup"): eff.setup(target) + eff.global_position = pos var prefix = "" if is_crit and is_overheal: prefix = "CRIT OVERHEAL! " @@ -2147,11 +2181,48 @@ func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool: var result = space_state.intersect_ray(query) if result: - # Hit something - wall blocks spell casting return false return true +func _is_valid_spell_target_exclude(target_pos: Vector2, caster_pos: Vector2, exclude_rid: RID) -> bool: + if dungeon_data.is_empty() or not dungeon_data.has("grid"): + return false + var tile_size = 16 + var tile_x: int = int(target_pos.x / tile_size) + var tile_y: int = int(target_pos.y / tile_size) + var grid = dungeon_data.grid + var map_size = dungeon_data.map_size + if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: + return false + if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 2 and grid[tile_x][tile_y] != 3: + return false + var space_state = get_world_2d().direct_space_state + var query = PhysicsRayQueryParameters2D.new() + query.from = caster_pos + query.to = target_pos + query.collision_mask = 64 + if exclude_rid != RID(): + query.exclude = [exclude_rid] + var result = space_state.intersect_ray(query) + return not result + +func _get_valid_spell_target_for_enemy(enemy_pos: Vector2, target_pos: Vector2, enemy_rid: RID) -> Vector2: + if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"): + return Vector2.ZERO + var dir = (target_pos - enemy_pos).normalized() + var step = 16.0 + var max_dist = enemy_pos.distance_to(target_pos) + step * 4 + var steps = int(max_dist / step) + 1 + for i in range(steps + 1): + var check_dist = min(float(i) * step, max_dist) + var check_pos = enemy_pos + dir * check_dist + var tile_pos = dungeon_tilemap_layer.local_to_map(check_pos - dungeon_tilemap_layer.global_position) + var tile_center = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position + if _is_valid_spell_target_exclude(tile_center, enemy_pos, enemy_rid): + return tile_center + return Vector2.ZERO + func _is_walkable_tile(tile_center: Vector2) -> bool: """True if tile is floor/door/corridor (not a wall). No raycast - use for adjacent spikes only.""" if dungeon_data.is_empty() or not dungeon_data.has("grid"): @@ -2246,8 +2317,8 @@ func _update_fog_of_war(delta: float) -> void: # Skip expensive updates if we're stationary in a corridor if in_corridor and not corridor_state_changed: # Check if player moved significantly (more than 1 tile) - var player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE)) - var player_moved = cached_corridor_player_tile.distance_to(player_tile) > 1 + var current_player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE)) + var player_moved = cached_corridor_player_tile.distance_to(current_player_tile) > 1 # 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 @@ -2299,10 +2370,10 @@ func _update_fog_of_war(delta: float) -> void: 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 - var player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) + var corridor_player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) # Cache corridor data - only rebuild if player moved more than 1 tile - var should_rebuild_corridor = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(player_tile) > 1 + var should_rebuild_corridor = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1 var corridor_mask: PackedInt32Array var corridor_rooms: Array @@ -2310,9 +2381,9 @@ func _update_fog_of_war(delta: float) -> void: if should_rebuild_corridor: # Rebuild corridor mask and rooms (expensive operation) - cached_corridor_mask = _build_corridor_mask(player_tile) - cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, player_tile) - cached_corridor_player_tile = player_tile + cached_corridor_mask = _build_corridor_mask(corridor_player_tile) + cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, corridor_player_tile) + cached_corridor_player_tile = corridor_player_tile # Build a set of allowed room IDs for fast lookup cached_corridor_allowed_room_ids = {} for room in cached_corridor_rooms: @@ -2785,6 +2856,14 @@ 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 + dead_players.clear() + respawn_all_check_running = false + + # Hide game over UI if it exists + var game_over_ui = get_node_or_null("GameOverUI") + if game_over_ui: + game_over_ui.visible = false print("GameWorld: Generating dungeon level ", current_level) @@ -3070,11 +3149,11 @@ func _render_dungeon(): return # Try to use existing TileMapLayer from scene, or create new one - var environment = get_node_or_null("Environment") + var env_node = get_node_or_null("Environment") - if environment: - dungeon_tilemap_layer = environment.get_node_or_null("DungeonLayer0") - dungeon_tilemap_layer_above = environment.get_node_or_null("TileMapLayerAbove") + if env_node: + dungeon_tilemap_layer = env_node.get_node_or_null("DungeonLayer0") + dungeon_tilemap_layer_above = env_node.get_node_or_null("TileMapLayerAbove") if not dungeon_tilemap_layer: # Create new TileMapLayer @@ -3249,6 +3328,9 @@ func _render_dungeon(): # Create stairs Area2D if stairs data exists _create_stairs_area() + + # Create entrance Area2D if entrance data exists (level > 1) + _create_entrance_area() # Randomize dungeon color scheme (seed-based) _apply_dungeon_color_scheme() @@ -3738,6 +3820,23 @@ func _send_dungeon_blob_sync(client_peer_id: int): "next_chunk": 0, "total_chunks": total_chunks } + + # Retry metadata if joiner never acks (lost ack = black map, no collision) + var pid = client_peer_id + var retry_timer = get_tree().create_timer(5.0) + retry_timer.timeout.connect(func(): + if not is_inside_tree() or not multiplayer.is_server(): + return + if not dungeon_chunk_acks.has(pid): + 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 + 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) + _sync_dungeon_to_client(pid, dungeon_data, dungeon_seed, current_level, _get_host_room()) + ) func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary, retry_count: int = 0): # OLD METHOD - kept for backwards compatibility, but should use _send_dungeon_blob_sync instead @@ -3961,6 +4060,7 @@ func _pack_dungeon_blob(): "interactable_objects": dungeon_data.get("interactable_objects", []), "traps": dungeon_data.get("traps", []), "stairs": dungeon_data.get("stairs", {}), + "entrance": dungeon_data.get("entrance", {}), "blocking_doors": dungeon_data.get("blocking_doors", []) } @@ -4308,11 +4408,16 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec pending_door_states = temp_pending_door_states print("GameWorld: Client - Restored ", defeated_enemies.size(), " defeated enemies, ", broken_objects.size(), " broken objects, ", pending_chest_opens.size(), " opened chests, and ", pending_door_states.size(), " door states after _clear_level()") - await get_tree().process_frame - await get_tree().process_frame - - # Send acknowledgment for metadata - _ack_dungeon_chunk.rpc_id(1, -1) # Send to server (peer 1) + # CRITICAL: Send ack via call_deferred - avoid await in RPC handler (can prevent ack from ever firing on some platforms) + # Host waits for this ack before sending blob chunks; lost ack = joiner gets black map, no collision + call_deferred("_deferred_ack_dungeon_metadata") + +func _deferred_ack_dungeon_metadata(): + # Client-only: send metadata ack after _clear_level has settled (called via call_deferred from _sync_dungeon_blob_metadata) + if multiplayer.is_server() or not is_inside_tree(): + return + _ack_dungeon_chunk.rpc_id(1, -1) + print("GameWorld: Client - [CHUNK 0] Sent deferred metadata ack to host") @rpc("authority", "reliable", "call_local") func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int, host_room: Dictionary = {}): @@ -4379,8 +4484,11 @@ func _sync_dungeon_blob_chunk(chunk_idx: int, chunk_bytes: PackedByteArray): # Store blob chunk if not dungeon_sync_chunks.has("blob_data"): dungeon_sync_chunks["blob_data"] = {} - dungeon_sync_chunks["blob_data"][chunk_idx] = chunk_bytes - dungeon_sync_received_chunks += 1 + var blob_data = dungeon_sync_chunks["blob_data"] + var is_new = not blob_data.has(chunk_idx) or blob_data[chunk_idx] == null + blob_data[chunk_idx] = chunk_bytes + if is_new: + dungeon_sync_received_chunks += 1 print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)") @@ -4468,7 +4576,28 @@ func _reassemble_dungeon_blob(): # Deserialize blob var full_dungeon_data = bytes_to_var(blob_bytes) if not full_dungeon_data is Dictionary: - print("GameWorld: Client - ERROR: Failed to deserialize dungeon blob!") + print("GameWorld: Client - ERROR: Failed to deserialize dungeon blob (not a Dictionary)!") + LogManager.log_error("GameWorld: Client - Dungeon blob deserialize failed", LogManager.CATEGORY_NETWORK) + return + + # Validate required map data (missing = black map, no collision) + if not full_dungeon_data.has("tile_grid") or not full_dungeon_data.tile_grid is Array: + print("GameWorld: Client - ERROR: Dungeon blob missing or invalid tile_grid!") + LogManager.log_error("GameWorld: Client - Dungeon blob missing tile_grid", LogManager.CATEGORY_NETWORK) + return + if not full_dungeon_data.has("grid") or not full_dungeon_data.grid is Array: + print("GameWorld: Client - ERROR: Dungeon blob missing or invalid grid!") + LogManager.log_error("GameWorld: Client - Dungeon blob missing grid", LogManager.CATEGORY_NETWORK) + return + if not full_dungeon_data.has("map_size"): + print("GameWorld: Client - ERROR: Dungeon blob missing map_size!") + LogManager.log_error("GameWorld: Client - Dungeon blob missing map_size", LogManager.CATEGORY_NETWORK) + return + var tile_grid_val = full_dungeon_data.tile_grid + var grid_val = full_dungeon_data.grid + if tile_grid_val.is_empty() or grid_val.is_empty(): + print("GameWorld: Client - ERROR: Dungeon blob has empty tile_grid or grid!") + LogManager.log_error("GameWorld: Client - Dungeon blob empty tile_grid/grid", LogManager.CATEGORY_NETWORK) return # Extract dungeon data @@ -5464,6 +5593,44 @@ func _sync_existing_trap_states_to_client(client_peer_id: int, retry_count: int print("GameWorld: Synced ", synced_trap_count, " trap states to client ", client_peer_id) +@rpc("any_peer", "reliable") +func _request_trap_disarm(trap_name: String): + # Joiner requests server to sync trap disarm (host never receives _sync_trap_state_by_name) + if not multiplayer.is_server(): + return + if not is_inside_tree(): + return + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var trap = entities_node.get_node_or_null(trap_name) + if not trap or not trap.is_in_group("trap"): + return + # Apply disarm locally on host, then broadcast to all clients + _apply_trap_state_by_name(trap_name, true, true) + _sync_trap_state_by_name.rpc(trap_name, true, true) + LogManager.log("GameWorld: Host applied trap disarm for " + trap_name + " (requested by joiner)", LogManager.CATEGORY_GAMEPLAY) + +func _apply_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool) -> void: + # Apply trap state locally (used by host when relaying joiner disarm) + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var trap = entities_node.get_node_or_null(trap_name) + if not trap or not trap.is_in_group("trap"): + return + if "is_detected" in trap: + trap.is_detected = is_detected + if "is_disarmed" in trap: + trap.is_disarmed = is_disarmed + if is_detected and "sprite" in trap and trap.sprite: + trap.sprite.modulate.a = 1.0 + if is_disarmed: + if "sprite" in trap and trap.sprite: + 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 + @rpc("authority", "reliable") func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool): # Client receives trap state sync by name (avoids node path RPC errors) @@ -5471,31 +5638,8 @@ func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: return if not multiplayer.is_server(): - var entities_node = get_node_or_null("Entities") - if not entities_node: - return - - var trap = entities_node.get_node_or_null(trap_name) - if trap and trap.is_in_group("trap"): - # Update trap state - if "is_detected" in trap: - trap.is_detected = is_detected - if "is_disarmed" in trap: - trap.is_disarmed = is_disarmed - - # Update visuals - if is_detected and "sprite" in trap and trap.sprite: - trap.sprite.modulate.a = 1.0 - - if is_disarmed: - if "sprite" in trap and trap.sprite: - 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 - - print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed) - else: - print("GameWorld: WARNING - Trap ", trap_name, " not found when syncing state") + _apply_trap_state_by_name(trap_name, is_detected, is_disarmed) + print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed) func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0): # Sync broken interactable objects to new client with retry logic @@ -5974,9 +6118,35 @@ func _sync_player_position_by_name(player_name: String, pos: Vector2): if not entities_node: return - var player = entities_node.get_node_or_null(player_name) + var player = entities_node.get_node_or_null(NodePath(str(player_name))) if player and player.has_method("_sync_teleport_position"): - player._sync_teleport_position(pos) + # Joiner's own player during fall: skip teleport so fall-from-sky plays; we're already visible + var self_falling = player.get("is_local_player") and player.is_local_player and player.get("spawn_landing") and player.spawn_landing + if not self_falling: + player._sync_teleport_position(pos) + player.visible = true + +@rpc("authority", "reliable") +func _ensure_player_visible_by_name(player_name: String): + # Visibility-only sync (no position update). Used for delayed joiner visibility retry. + if not is_inside_tree(): + return + if multiplayer.is_server(): + return + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + var player = entities_node.get_node_or_null(NodePath(str(player_name))) + if player: + player.visible = true + # Joiner's own player: also show lights (they were hidden in _ready) + if player.get("is_local_player") and player.is_local_player: + var cl = player.get("cone_light") + if cl: + cl.visible = true + var pl = player.get("point_light") + if pl: + pl.visible = true @rpc("authority", "reliable") func _sync_door_open_by_name(door_name: String): @@ -6176,12 +6346,12 @@ func _clear_level(): pending_chest_opens.clear() # Clear tilemap layers - ensure we get references from scene if they exist - var environment = get_node_or_null("Environment") - if environment: - var layer0 = environment.get_node_or_null("DungeonLayer0") + var env_node = get_node_or_null("Environment") + if env_node: + var layer0 = env_node.get_node_or_null("DungeonLayer0") if layer0: layer0.clear() - var layer_above = environment.get_node_or_null("TileMapLayerAbove") + var layer_above = env_node.get_node_or_null("TileMapLayerAbove") if layer_above: layer_above.clear() @@ -6210,6 +6380,16 @@ func _clear_level(): if stairs_area: stairs_area.free() # Use free() for immediate removal + # Remove entrance area + var entrance_area = get_node_or_null("EntranceArea") + if entrance_area: + entrance_area.free() # Use free() for immediate removal + + # Remove entrance gate door (under Entities; reuse entities_node from above) + var entrance_gate = entities_node.get_node_or_null("EntranceGateDoor") if entities_node else null + if entrance_gate: + entrance_gate.free() # Use free() for immediate removal + # Clear dungeon data (but keep it for now until new one is generated) # dungeon_data = {} # Don't clear yet, wait for new generation @@ -6315,6 +6495,8 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): 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 # 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 @@ -6341,6 +6523,7 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): LogManager.log("GameWorld: Sent player " + player.name + " equipment to new joiner " + str(new_peer_id), LogManager.CATEGORY_GAMEPLAY) # Notify all existing players about the new joiner's positions (without moving them) + # _sync_player_position_by_name also sets visible = true on clients var existing_peers = multiplayer.get_peers() for existing_peer_id in existing_peers: if existing_peer_id != new_peer_id: @@ -6348,6 +6531,32 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): if player.is_inside_tree() and is_instance_valid(player): _sync_player_position_by_name.rpc_id(existing_peer_id, player.name, player.global_position) LogManager.log("GameWorld: Notified existing peer " + str(existing_peer_id) + " about new joiner player " + player.name + " position (" + str(player.global_position) + ")", LogManager.CATEGORY_GAMEPLAY) + # Delayed retry: ensure joiner becomes visible on clients if spawn arrived after first position sync + var joiner_names: Array = [] + for player in new_joiner_players: + if player.is_inside_tree() and is_instance_valid(player): + joiner_names.append(player.name) + if joiner_names.size() > 0: + get_tree().create_timer(1.0).timeout.connect(func(): + if not is_inside_tree() or not multiplayer.is_server(): + return + var entities_node = get_node_or_null("Entities") + # Host: ensure joiner's players are visible (host never receives visibility RPCs) + if entities_node: + for pname in joiner_names: + var p = entities_node.get_node_or_null(NodePath(str(pname))) + if p: + p.visible = true + # Clients (existing peers): RPC ensure_visible + for existing_peer_id in multiplayer.get_peers(): + if existing_peer_id != new_peer_id: + for pname in joiner_names: + _ensure_player_visible_by_name.rpc_id(existing_peer_id, pname) + # Joiner: also send ensure_visible for their own player(s) as second chance + for pname in joiner_names: + _ensure_player_visible_by_name.rpc_id(new_peer_id, pname) + LogManager.log("GameWorld: Sent delayed joiner visibility retry (host + peers + joiner)", LogManager.CATEGORY_GAMEPLAY) + ) func _move_all_players_to_start_room(): # Move all players to the start room of the new level @@ -6388,42 +6597,397 @@ func _move_all_players_to_start_room(): # The server will call _sync_teleport_position for each player return - var spawn_index = 0 - for player in sorted_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 player " + 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: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room at " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) - - # Server: Sync position to all clients for ALL players (including server's own) - # Use GameWorld RPC to avoid node path resolution issues - _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, new_pos]) - - 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 was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) - - player.global_position = fallback_pos - LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room center at " + str(player.global_position), LogManager.CATEGORY_GAMEPLAY) - - # Server: Sync position to all clients for ALL players (including server's own) - _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, fallback_pos]) + # Check if entrance exists (level > 1) + 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 players inside entrance and start walk-out sequence + _position_players_in_entrance(sorted_players) + else: + # Normal spawn positioning (level 1 or no entrance) + var spawn_index = 0 + for player in sorted_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 player " + 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: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room at " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) + + # Server: Sync position to all clients for ALL players (including server's own) + # Use GameWorld RPC to avoid node path resolution issues + _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, new_pos]) + + 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 was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) + + player.global_position = fallback_pos + LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room center at " + str(player.global_position), LogManager.CATEGORY_GAMEPLAY) + + # Server: Sync position to all clients for ALL players (including server's own) + _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, fallback_pos]) + +func _position_players_in_entrance(players: Array): + # Position all players inside the entrance and start walk-out sequence + if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty(): + return + + var entrance_data = dungeon_data.entrance + if not entrance_data.has("world_pos") or not entrance_data.has("world_size"): + return + + # Reset walk-out state + players_walking_out.clear() + entrance_walk_out_complete = false + + # Position each player inside the entrance (stacked slightly) + var entrance_center = entrance_data.world_pos + var player_spacing = 8.0 # Small spacing between players + + for i in range(players.size()): + var player = players[i] + if not is_instance_valid(player): + 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 player_pos = entrance_center + Vector2(offset_x, offset_y) + + player.global_position = player_pos + + # Lock controls and add to walk-out list + player.controls_disabled = true + players_walking_out.append(player) + + # Play "go_down_stairs" when player appears in entrance (reversed if go_down_stairs_reversed.mp3 exists) + _play_entrance_appear_sound(player_pos) + + # Sync position and control state + _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, player_pos]) + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_player_entrance_walk_out", [player.name, true]) + + LogManager.log("GameWorld: Positioned player " + player.name + " in entrance at " + str(player_pos), LogManager.CATEGORY_GAMEPLAY) + + # Start walk-out sequence after a brief delay + await get_tree().create_timer(0.5).timeout + _start_entrance_walk_out() + +func _start_entrance_walk_out(): + # Make all players walk out of the entrance + if players_walking_out.is_empty(): + entrance_walk_out_complete = true + _close_entrance() + return + + # Get entrance data + var entrance_data = dungeon_data.entrance + var entrance_center = entrance_data.world_pos + var start_room = dungeon_data.start_room + var room_center = Vector2((start_room.x + float(start_room.w) / 2.0) * 16, (start_room.y + float(start_room.h) / 2.0) * 16) + + # Walk straight into room: use entrance wall dir -> "into room" (perpendicular) + 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 + "DOWN": walk_direction = Vector2.UP + "LEFT": walk_direction = Vector2.RIGHT + "RIGHT": walk_direction = Vector2.LEFT + _: pass + + # Target positions: free tiles straight ahead (sort by distance along walk_direction) + var target_positions: Array = [] + var free_tiles = _get_free_floor_tiles_in_room(start_room) + if free_tiles.size() > 0: + var tile_size = 16 + var scored: Array = [] + for ft in free_tiles: + var p = Vector2(ft.x * tile_size + tile_size / 2.0, ft.y * tile_size + tile_size / 2.0) + var along = (p - entrance_center).dot(walk_direction) + scored.append({"pos": p, "along": along}) + scored.sort_custom(func(a, b): return a.along > b.along) + for i in range(players_walking_out.size()): + if i < scored.size(): + target_positions.append(scored[i].pos) + elif i < player_manager.spawn_points.size(): + target_positions.append(player_manager.spawn_points[i]) + else: + target_positions.append(room_center) + else: + for i in range(players_walking_out.size()): + if i < player_manager.spawn_points.size(): + target_positions.append(player_manager.spawn_points[i]) + else: + target_positions.append(room_center) + + # Make each player walk straight to their target (same direction for all: into room) + for i in range(players_walking_out.size()): + var player = players_walking_out[i] + if not is_instance_valid(player): + continue + + var target_pos = target_positions[i] if i < target_positions.size() else room_center + var direction = (target_pos - player.global_position).normalized() + if direction.length() < 0.1: + direction = walk_direction + + player.velocity = direction * 120.0 # Walk speed for cut-scene + + # Store target position for checking completion + player.set_meta("entrance_walk_target", target_pos) + + # Sync walk target via RPC + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_player_walk_target", [player.name, target_pos]) + + # Check when all players have reached their targets + _check_entrance_walk_out_complete() + +func _check_entrance_walk_out_complete(): + # Check if all players have exited the entrance + if players_walking_out.is_empty(): + entrance_walk_out_complete = true + _close_entrance() + return + + # Remove players that have reached their targets + var players_to_remove = [] + for player in players_walking_out: + if not is_instance_valid(player): + players_to_remove.append(player) + continue + + # Consider player "out" only when they've reached their walk target (inside the room). + # Do NOT use "far from entrance" — that closed the gate when exiting the room / entering another. + var has_reached_target = false + 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) + has_reached_target = true + player.remove_meta("entrance_walk_target") + + if has_reached_target: + # Player has exited - unlock controls and stop velocity + player.controls_disabled = false + player.velocity = Vector2.ZERO + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_player_entrance_walk_out", [player.name, false]) + players_to_remove.append(player) + + # Remove exited players + for player in players_to_remove: + players_walking_out.erase(player) + + # If all players are out, close entrance + if players_walking_out.is_empty(): + entrance_walk_out_complete = true + _close_entrance() + else: + # Check again after a short delay + await get_tree().create_timer(0.1).timeout + _check_entrance_walk_out_complete() + +func _play_entrance_appear_sound(at_pos: Vector2): + # Play go_down_stairs when player appears in entrance; use reversed if available + var path_reversed = "res://assets/audio/sfx/walk/go_down_stairs_reversed.mp3" + var path_normal = "res://assets/audio/sfx/walk/go_down_stairs.mp3" + var path_use = path_normal + if ResourceLoader.exists(path_reversed): + path_use = path_reversed + var stream = load(path_use) as AudioStream + if not stream: + return + var snd = AudioStreamPlayer2D.new() + snd.stream = stream + snd.global_position = at_pos + add_child(snd) + snd.play() + snd.finished.connect(func(): snd.queue_free()) + +func _entrance_dir_to_door_dir(d: String) -> String: + # Dungeon uses "UP"/"DOWN"/"LEFT"/"RIGHT"; door expects "Up"/"Down"/"Left"/"Right" + match d: + "UP": return "Up" + "DOWN": return "Down" + "LEFT": return "Left" + "RIGHT": return "Right" + _: return d + +func _entrance_gate_position(entrance_data: Dictionary) -> Vector2: + # Position gate like blocking doors: center of door on wall, per direction. + # entrance_data has world_pos (center of stairs), world_size, dir. + const TILE_SIZE: float = 16.0 + var c = entrance_data.world_pos + var sz = entrance_data.world_size + var d = entrance_data.get("dir", "UP") + match d: + "UP": + return Vector2(c.x, c.y - sz.y / 2.0 + TILE_SIZE / 2.0) + "DOWN": + return Vector2(c.x, c.y + sz.y / 2.0 - TILE_SIZE / 2.0) + "LEFT": + return Vector2(c.x - sz.x / 2.0 + TILE_SIZE / 2.0, c.y) + "RIGHT": + return Vector2(c.x + sz.x / 2.0 - TILE_SIZE / 2.0, c.y) + _: + return c + +func _close_entrance(): + # Close entrance by adding a non-openable gate_door + if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty(): + return + + var entrance_data = dungeon_data.entrance + if not entrance_data.has("world_pos") or not entrance_data.has("dir"): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + push_error("GameWorld: Could not find Entities node for entrance gate!") + return + + # 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 + + # Load door scene + var door_scene = load("res://scenes/door.tscn") + if not door_scene: + push_error("GameWorld: Could not load door scene for entrance gate!") + return + + var gate_dir = _entrance_dir_to_door_dir(entrance_data.dir) + var gate_pos = _entrance_gate_position(entrance_data) + + # Create gate door — spawn OPEN like other GateDoors, then animate closed (plays close sound) + # Position like blocking doors: center of door on wall per direction + var gate_door = door_scene.instantiate() + 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.requires_enemies = false + gate_door.requires_switch = false + + gate_door.global_position = gate_pos + + if multiplayer.has_multiplayer_peer(): + gate_door.set_multiplayer_authority(get_multiplayer_authority()) + + entities_node.add_child(gate_door) + + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_entrance_gate_door", [gate_pos, gate_dir]) + + # Hide "ENTRANCE" label + var entrance_area = get_node_or_null("EntranceArea") + if entrance_area: + var entrance_label = entrance_area.get_node_or_null("EntranceLabel") + if entrance_label: + entrance_label.visible = false + + # Animate gate closed after _ready_after_setup runs (plays SfxCloseGateDoor, syncs to clients) + var gd = gate_door + get_tree().create_timer(0.12).timeout.connect(func(): + if is_instance_valid(gd) and gd.has_method("_close"): + gd._close() + ) + + LogManager.log("GameWorld: Closed entrance with gate door at " + str(gate_pos), LogManager.CATEGORY_DUNGEON) + +@rpc("any_peer", "reliable") +func _sync_player_entrance_walk_out(player_name: String, is_walking_out: bool): + # Sync entrance walk-out state to clients + var players = get_tree().get_nodes_in_group("player") + for p in players: + if p.name == player_name: + p.controls_disabled = is_walking_out + if not is_walking_out: + p.velocity = Vector2.ZERO + p.remove_meta("entrance_walk_target") + break + +@rpc("any_peer", "reliable") +func _sync_player_walk_target(player_name: String, target_pos: Vector2): + # Sync walk target to clients (cut-scene walk into room) + var players = get_tree().get_nodes_in_group("player") + for p in players: + if p.name == player_name: + var direction = (target_pos - p.global_position).normalized() + if direction.length() < 0.1: + direction = Vector2.DOWN + p.velocity = direction * 120.0 + p.set_meta("entrance_walk_target", target_pos) + break + +@rpc("any_peer", "reliable") +func _sync_entrance_gate_door(gate_pos: Vector2, gate_dir: String): + # Sync entrance gate door creation to clients + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var existing_gate = entities_node.get_node_or_null("EntranceGateDoor") + if existing_gate: + return # Already exists + + var door_scene = load("res://scenes/door.tscn") + if not door_scene: + return + + var gate_door = door_scene.instantiate() + 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.requires_enemies = false + gate_door.requires_switch = false + gate_door.global_position = gate_pos + + if multiplayer.has_multiplayer_peer(): + gate_door.set_multiplayer_authority(1) + + entities_node.add_child(gate_door) + + # Hide "ENTRANCE" label on clients too + var entrance_area = get_node_or_null("EntranceArea") + if entrance_area: + var entrance_label = entrance_area.get_node_or_null("EntranceLabel") + if entrance_label: + entrance_label.visible = false + +func _get_free_floor_tiles_in_room(room: Dictionary) -> Array: + # Get free floor tiles in a room (not walls, doors, or stairs) + var free_tiles = [] + + # Check each tile in the room (excluding walls) + for x in range(room.x + 2, room.x + room.w - 2): + 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 + free_tiles.append({"x": x, "y": y}) + + return free_tiles func _create_stairs_area(): # Remove existing stairs area if any @@ -6467,6 +7031,71 @@ func _create_stairs_area(): print("GameWorld: Created stairs Area2D at ", stairs_data.world_pos, " with size ", stairs_data.world_size) +func _create_entrance_area(): + # Remove existing entrance area if any + var existing_entrance = get_node_or_null("EntranceArea") + if existing_entrance: + existing_entrance.queue_free() + + # Check if entrance data exists (only for level > 1) + if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty(): + return + + var entrance_data = dungeon_data.entrance + if not entrance_data.has("world_pos") or not entrance_data.has("world_size"): + return + + # Create entrance Area2D programmatically (similar to stairs) + var entrance_area = Area2D.new() + entrance_area.name = "EntranceArea" + + # Set collision layer/mask BEFORE adding to scene + entrance_area.collision_layer = 0 + entrance_area.collision_mask = 1 # Detect players (layer 1) + + # Add collision shape + var collision_shape = CollisionShape2D.new() + var rect_shape = RectangleShape2D.new() + rect_shape.size = entrance_data.world_size + collision_shape.shape = rect_shape + entrance_area.add_child(collision_shape) + + # Set position + entrance_area.global_position = entrance_data.world_pos + + # Add "ENTRANCE" label above (like EXIT / DISARM) + var label = Label.new() + label.name = "EntranceLabel" + label.text = "ENTRANCE" + label.add_theme_font_size_override("font_size", 16) + label.add_theme_color_override("font_color", Color.YELLOW) + label.add_theme_color_override("font_outline_color", Color.BLACK) + label.add_theme_constant_override("outline_size", 2) + label.position = Vector2(-35, -30) + label.z_index = 100 + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + entrance_area.add_child(label) + + # Add to scene AFTER everything is set up + add_child(entrance_area) + + print("GameWorld: Created entrance Area2D at ", entrance_data.world_pos, " with size ", entrance_data.world_size) + +@rpc("any_peer", "reliable") +func _request_player_reached_stairs(player_peer_id: int): + # Client notifies server that they reached exit stairs (LEVEL COMPLETE) + if not multiplayer.is_server(): + return + var players = get_tree().get_nodes_in_group("player") + var target = null + for p in players: + if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id: + target = p + break + if target and is_instance_valid(target): + LogManager.log("GameWorld: Server received stairs reach from client (peer " + str(player_peer_id) + "), triggering level complete", LogManager.CATEGORY_DUNGEON) + _on_player_reached_stairs(target) + func _on_player_reached_stairs(player: Node): # Player reached stairs - trigger level complete if not multiplayer.is_server() and multiplayer.has_multiplayer_peer(): @@ -6517,6 +7146,9 @@ func _on_player_reached_stairs(player: Node): # Fade out player _fade_out_player(player) + # Stop background music when level completes + _stop_bg_music() + # 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) @@ -6682,10 +7314,19 @@ func _fade_out_player(player: Node): func _fade_in_all_players(): # Fade in all players after level transition var players = get_tree().get_nodes_in_group("player") + var fade_tweens = [] for player in players: - _fade_in_player(player) + var tween = _fade_in_player(player) + if tween: + fade_tweens.append(tween) + + # 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) + _start_bg_music() -func _fade_in_player(player: Node): +func _fade_in_player(player: Node) -> Tween: # Fade in all sprite layers var fade_tween = create_tween() fade_tween.set_parallel(true) @@ -6718,6 +7359,8 @@ func _fade_in_player(player: Node): if sprite_layer: sprite_layer.modulate.a = 0.0 # Start invisible fade_tween.tween_property(sprite_layer, "modulate:a", 1.0, 1.0) + + return fade_tween func _show_black_fade_overlay(): # Create black fade overlay for player who reached exit @@ -6812,6 +7455,36 @@ func _show_level_complete_ui(level_time: float = 0.0): level_time, current_level ) + + # Play level complete sound at 60% volume + _play_level_complete_sound() + +func _play_level_complete_sound(): + # Play level complete sound at 60% volume + var sound_path = "res://assets/audio/sfx/level_complete_03.wav" + if ResourceLoader.exists(sound_path): + var sound_stream = load(sound_path) as AudioStream + if sound_stream: + # 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 + add_child(audio_player) + audio_player.play() + # Clean up after sound finishes + audio_player.finished.connect(func(): audio_player.queue_free()) + +func _start_bg_music(): + # Start background music (only if not already playing) + if bg_music and not bg_music.playing: + bg_music.play() + print("GameWorld: Started background music") + +func _stop_bg_music(): + # Stop background music + if bg_music and bg_music.playing: + bg_music.stop() + print("GameWorld: Stopped background music") func _show_level_number(): # Show level number text @@ -7122,6 +7795,248 @@ func _broadcast_object_break(obj_name: String): if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server(): _rpc_to_ready_peers("_sync_object_break", [obj_name]) +func _register_player_died(player: Node): + # Track dead players for server-authoritative "all dead" respawn. + # 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): + return + dead_players[n] = true + if not respawn_all_check_running: + respawn_all_check_running = true + _run_respawn_all_check() + elif multiplayer.has_multiplayer_peer(): + _request_register_player_died.rpc_id(1, peer_id) + else: + # Single-player: we are "server" + if dead_players.has(n): + return + dead_players[n] = true + if not respawn_all_check_running: + respawn_all_check_running = true + _run_respawn_all_check() + +@rpc("any_peer", "reliable") +func _request_register_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 n.is_empty() or dead_players.has(n): + return + dead_players[n] = true + if not respawn_all_check_running: + respawn_all_check_running = true + _run_respawn_all_check() + 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"): + return false + var all_p = pm.get_all_players() + if all_p.is_empty(): + return false + for p in all_p: + if not is_instance_valid(p): + return false + 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: + return false + return true + +func _run_respawn_all_check(): + # Server-only coroutine: wait until all players dead, then game over + respawn_all. + while is_inside_tree() and respawn_all_check_running: + await get_tree().create_timer(0.2).timeout + if not _are_all_players_dead_server(): + continue + break + respawn_all_check_running = false + if not is_inside_tree(): + return + if game_over_triggered: + pass # Already shown (e.g. by earlier logic) + else: + game_over_triggered = true + _show_game_over_local() + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + _sync_show_game_over.rpc() + await get_tree().create_timer(0.5).timeout + if not is_inside_tree(): + return + dead_players.clear() + respawn_all_ready.emit() + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + _sync_respawn_all.rpc() + +@rpc("any_peer", "reliable") +func _sync_respawn_all(): + if multiplayer.is_server(): + 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 + + game_over_triggered = true + + # Show game over UI locally + _show_game_over_local() + + # Sync to all clients + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + _sync_show_game_over.rpc() + +func _show_game_over_local(): + # Create or show game over UI + var game_over_ui = get_node_or_null("GameOverUI") + if not game_over_ui: + # 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) + add_child(canvas_layer) + + # Load standard font + var standard_font = load("res://assets/fonts/standard_font.png") as FontFile + var theme = Theme.new() + if standard_font: + theme.default_font = standard_font + theme.default_font_size = 10 + + var vbox = VBoxContainer.new() + vbox.theme = theme + canvas_layer.add_child(vbox) + + # 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_right = screen_size.x / 2 + vbox.offset_top = -100 + vbox.offset_bottom = screen_size.y / 2 - 100 + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + # Title - "GAME OVER" in large size + var title = Label.new() + title.name = "TitleLabel" + title.text = "GAME OVER" + title.theme = theme + title.add_theme_font_size_override("font_size", 72) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_child(title) + + game_over_ui = canvas_layer + + if game_over_ui: + game_over_ui.visible = true + + # Fade in + var vbox = game_over_ui.get_child(0) if game_over_ui.get_child_count() > 0 else null + if vbox: + vbox.modulate.a = 0.0 + var fade_in = create_tween() + fade_in.tween_property(vbox, "modulate:a", 1.0, 0.5) + + # Play game over sound at 60% volume + _play_game_over_sound() + + # Stop background music when all players die + _stop_bg_music() + + # Fade out game graphics when GAME OVER is shown + _fade_out_game_graphics() + +@rpc("any_peer", "reliable") +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) + game_over_triggered = true + _show_game_over_local() + +func _play_game_over_sound(): + # Play game over sound at 60% volume + var sound_path = "res://assets/audio/sfx/level_fail_02.wav" + if ResourceLoader.exists(sound_path): + var sound_stream = load(sound_path) as AudioStream + if sound_stream: + # 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 + add_child(audio_player) + audio_player.play() + # Clean up after sound finishes + audio_player.finished.connect(func(): audio_player.queue_free()) + +func _fade_out_game_graphics(): + # Fade out game graphics when GAME OVER is shown + if not canvas_modulate: + return + + # Fade out CanvasModulate (darken the game) + var fade_tween = create_tween() + fade_tween.tween_property(canvas_modulate, "color", Color(0.0, 0.0, 0.0, 1.0), 0.5) + + # Also fade out Environment (tilemaps) + if environment: + environment.modulate.a = 1.0 + fade_tween.parallel().tween_property(environment, "modulate:a", 0.0, 0.5) + +func _fade_in_game_graphics(): + # Fade in game graphics quickly when GAME OVER is removed + if not canvas_modulate: + return + + # Get original CanvasModulate color (restore original brightness) + var original_color = Color(0.69140625, 0.69140625, 0.69140625, 1) + + # 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) + + # Also fade in Environment (tilemaps) + if environment: + environment.modulate.a = 0.0 + fade_tween.parallel().tween_property(environment, "modulate:a", 1.0, 0.2) + +func _hide_game_over(): + # Hide GAME OVER screen when player respawns + if not game_over_triggered: + return # Not shown, nothing to hide + + game_over_triggered = false + + var game_over_ui = get_node_or_null("GameOverUI") + if game_over_ui: + game_over_ui.visible = false + + # Fade in game graphics quickly + _fade_in_game_graphics() + + LogManager.log("GameWorld: Hidden GAME OVER screen and faded in graphics", LogManager.CATEGORY_GAMEPLAY) + @rpc("any_peer", "reliable") func _sync_arrow_collected(arrow_name: String): # Route arrow collection through game_world to avoid node path issues (RPC was on arrow). @@ -7788,7 +8703,8 @@ func _spawn_enemy_spawner(i_position: Vector2, room: Dictionary, spawner_data: D "res://scenes/enemy_rat.tscn", "res://scenes/enemy_humanoid.tscn", "res://scenes/enemy_slime.tscn", - "res://scenes/enemy_bat.tscn" + "res://scenes/enemy_bat.tscn", + "res://scenes/enemy_hand.tscn" ] # Load scenes and add to typed array diff --git a/src/scripts/ingame_hud.gd b/src/scripts/ingame_hud.gd index 9652578..1cefa7a 100644 --- a/src/scripts/ingame_hud.gd +++ b/src/scripts/ingame_hud.gd @@ -3,7 +3,7 @@ extends CanvasLayer # Ingame HUD - Displays player health, level, time, and boss health var label_life: Label = null -var texture_progress_bar_hp: TextureProgressBar = null +var progress_bar_hp: ProgressBar = null var label_hp_value: Label = null var vbox_mp: VBoxContainer = null var progress_bar_mp: ProgressBar = null @@ -38,7 +38,6 @@ func _ready(): # Find nodes safely (using get_node_or_null to avoid crashes) label_life = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerLIFE/LabelLife") - texture_progress_bar_hp = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerLIFE/TextureProgressBarHP") label_keys = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerKeys/LabelKeys") label_keys_value = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerKeys/HBoxContainer/LabelKeysValue") label_level = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerLevel/LabelLevel") @@ -232,6 +231,23 @@ func _setup_hp_mp_ui() -> void: var hbox = get_node_or_null("UpperLeft/HBoxContainer") if not life_vbox or not hbox: return + var old_heart_bar = life_vbox.get_node_or_null("TextureProgressBarHP") + if old_heart_bar: + life_vbox.remove_child(old_heart_bar) + old_heart_bar.queue_free() + # HP bar (inventory-style ProgressBar, same as inventory - no hearts) + progress_bar_hp = ProgressBar.new() + progress_bar_hp.custom_minimum_size = Vector2(100, 12) + progress_bar_hp.show_percentage = false + var bg_hp = StyleBoxFlat.new() + bg_hp.bg_color = Color(0.2, 0.2, 0.2, 0.8) + bg_hp.set_border_width_all(1) + bg_hp.border_color = Color(0.4, 0.4, 0.4) + progress_bar_hp.add_theme_stylebox_override("background", bg_hp) + var fill_hp = StyleBoxFlat.new() + fill_hp.bg_color = Color(0.85, 0.2, 0.2) + progress_bar_hp.add_theme_stylebox_override("fill", fill_hp) + life_vbox.add_child(progress_bar_hp) # HP value label (curr/max, like inventory) label_hp_value = Label.new() label_hp_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -280,26 +296,21 @@ func _setup_hp_mp_ui() -> void: hbox.move_child(vbox_mp, 1) func _update_player_health(): - if not local_player or not texture_progress_bar_hp: + if not local_player or not progress_bar_hp: return - var health = 0 - var max_health = 100 + var health = 0.0 + var max_health = 100.0 - # Try to get health from character_stats first (property always exists in player.gd) if local_player.character_stats: health = local_player.character_stats.hp max_health = local_player.character_stats.maxhp else: - # Fallback to direct properties (these are getters in player.gd, always available) health = local_player.current_health max_health = local_player.max_health - # Update progress bar - texture_progress_bar_hp.max_value = max_health - texture_progress_bar_hp.value = health - - # HP value label (like inventory) + progress_bar_hp.max_value = max(1.0, max_health) + progress_bar_hp.value = health if label_hp_value: label_hp_value.text = str(int(health)) + "/" + str(int(max_health)) diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index f9ca1a8..f39c83f 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -84,13 +84,13 @@ var level_up_stat_buttons: Array = [] # Buttons for STR, DEX, INT, END, WIS, LCK var level_up_stat_container: HBoxContainer = null var selected_level_up_stat_index: int = -1 const STAT_DESCRIPTIONS: Dictionary = { - "str": "STR: Physical damage, carry capacity.", - "dex": "DEX: Dodge, hit chance, move & attack speed.", - "int": "INT: Spell damage, mana, sight.", - "end": "END: Max HP.", - "wis": "WIS: Mana regen, resistances.", - "lck": "LCK: Critical hit chance.", - "per": "PER: Trap detection, perception." + "str": "STR (Strength): Increases melee and bow damage. Raises carry capacity so you can hold more items before becoming encumbered.", + "dex": "DEX (Dexterity): Improves dodge chance and hit chance. Makes you move and attack faster.", + "int": "INT (Intelligence): Boosts spell damage (flames, frost, heal). Increases max mana and vision range.", + "end": "END (Endurance): Increases max HP. Each point raises your maximum health.", + "wis": "WIS (Wisdom): Improves mana regeneration and resistances to certain effects.", + "lck": "LCK (Luck): Increases critical hit chance. Critical hits deal bonus damage and partially ignore defense.", + "per": "PER (Perception): Improves trap detection and perception. Helps you spot hazards and secrets." } # Equipment slot buttons @@ -331,6 +331,9 @@ func _setup_level_up_ui() -> void: btn.text = stat_name.to_upper() btn.custom_minimum_size = Vector2(32, 24) btn.flat = true + btn.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85)) + btn.add_theme_color_override("font_hover_color", Color(0.4, 1.0, 0.5)) + btn.add_theme_color_override("font_focus_color", Color(0.4, 1.0, 0.5)) if ResourceLoader.exists("res://assets/fonts/standard_font.png"): var fr = load("res://assets/fonts/standard_font.png") if fr: @@ -341,6 +344,7 @@ func _setup_level_up_ui() -> void: btn.mouse_entered.connect(_on_level_up_stat_hover_entered.bind(stat_name)) btn.mouse_exited.connect(_on_level_up_stat_hover_exited) btn.gui_input.connect(_on_level_up_stat_gui_input.bind(stat_name, btn)) + btn.focus_entered.connect(_on_level_up_stat_focus_entered.bind(stat_name)) level_up_stat_container.add_child(btn) level_up_stat_buttons.append(btn) stats_panel.add_child(level_up_stat_container) @@ -349,6 +353,8 @@ func _setup_level_up_ui() -> void: func _on_level_up_stat_pressed(stat_name: String) -> void: if not local_player or not local_player.character_stats: return + if not _can_use_inventory(): + return if local_player.character_stats.allocate_stat_point(stat_name): if sfx_armour: sfx_armour.play() @@ -375,10 +381,23 @@ func _on_level_up_stat_hover_entered(stat_name: String) -> void: info_label.text = STAT_DESCRIPTIONS[stat_name] func _on_level_up_stat_hover_exited() -> void: - if info_label: - _update_info_panel() + if not info_label: + return + var fc = get_viewport().gui_get_focus_owner() + if level_up_stat_container and fc and is_instance_valid(level_up_stat_container) and fc.get_parent() == level_up_stat_container: + var idx = level_up_stat_buttons.find(fc) + if idx >= 0 and idx < CharacterStats.LEVEL_UP_STAT_NAMES.size(): + var sn = CharacterStats.LEVEL_UP_STAT_NAMES[idx] + if sn in STAT_DESCRIPTIONS: + info_label.text = STAT_DESCRIPTIONS[sn] + return + _update_info_panel() -func _on_level_up_stat_gui_input(event: InputEvent, stat_name: String, btn: Button) -> void: +func _on_level_up_stat_focus_entered(stat_name: String) -> void: + if info_label and stat_name in STAT_DESCRIPTIONS: + info_label.text = STAT_DESCRIPTIONS[stat_name] + +func _on_level_up_stat_gui_input(event: InputEvent, stat_name: String, _btn: Button) -> void: if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER or event.keycode == KEY_SPACE: _on_level_up_stat_pressed(stat_name) @@ -573,6 +592,16 @@ func _has_equipment_in_slot(slot_name: String) -> bool: return false return local_player.character_stats.equipment[slot_name] != null +func _can_use_inventory() -> bool: + # Block equip/unequip/use/drop/level-up when dead + if not local_player: + return false + if "is_dead" in local_player and local_player.is_dead: + return false + if "is_processing_death" in local_player and local_player.is_processing_death: + return false + return true + func _find_next_filled_equipment_slot(start_index: int, direction: int) -> int: # Find next filled equipment slot, or -1 if none found var current_index = start_index @@ -1110,11 +1139,22 @@ func _navigate_inventory(direction: String): if inventory_selection_col > 0: inventory_selection_col -= 1 else: - # Wrap to end of previous row if inventory_selection_row > 0: inventory_selection_row -= 1 var row = inventory_rows_list[inventory_selection_row] inventory_selection_col = row.get_child_count() - 1 + elif local_player and local_player.character_stats and local_player.character_stats.pending_level_up and local_player.character_stats.pending_stat_points > 0 and level_up_stat_buttons.size() > 0: + selected_type = "level_up_stat" + selected_level_up_stat_index = 0 + selected_slot = "" + selected_item = null + level_up_stat_buttons[0].call_deferred("grab_focus") + if info_label and CharacterStats.LEVEL_UP_STAT_NAMES.size() > 0: + var sn = CharacterStats.LEVEL_UP_STAT_NAMES[0] + if sn in STAT_DESCRIPTIONS: + info_label.text = STAT_DESCRIPTIONS[sn] + _update_selection_rectangle() + return "right": if inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] @@ -1182,7 +1222,7 @@ func _navigate_equipment(direction: String): if next_index >= 0: equipment_selection_index = next_index "up": - # Find next filled slot in row above (same column) + # Find next filled slot in row above (same column), or go to stats var current_row: int = floor(equipment_selection_index / 3.0) var current_col = equipment_selection_index % 3 if current_row > 0: @@ -1191,11 +1231,20 @@ func _navigate_equipment(direction: String): if _has_equipment_in_slot(target_slot): equipment_selection_index = target_index else: - # Skip to next filled slot in that row var next_index = _find_next_filled_equipment_slot(target_index - 1, 1) - if next_index >= 0 and next_index < 3: # Make sure it's in row 0 + if next_index >= 0 and next_index < 3: equipment_selection_index = next_index - # Can't go up from equipment (already at top) + elif local_player and local_player.character_stats and local_player.character_stats.pending_level_up and local_player.character_stats.pending_stat_points > 0 and level_up_stat_buttons.size() > 0: + selected_type = "level_up_stat" + selected_level_up_stat_index = 0 + selected_slot = "" + selected_item = null + level_up_stat_buttons[0].call_deferred("grab_focus") + if info_label and CharacterStats.LEVEL_UP_STAT_NAMES.size() > 0: + var sn = CharacterStats.LEVEL_UP_STAT_NAMES[0] + if sn in STAT_DESCRIPTIONS: + info_label.text = STAT_DESCRIPTIONS[sn] + return "down": # Find next filled slot in row below (same column), or move to inventory var current_row: int = floor(equipment_selection_index / 3.0) @@ -1274,8 +1323,20 @@ func _navigate_level_up_stats(direction: String) -> void: var sn = CharacterStats.LEVEL_UP_STAT_NAMES[selected_level_up_stat_index] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] - "up", "down": + "up": pass + "down": + selected_type = "equipment" + selected_level_up_stat_index = -1 + var next_index = _find_next_filled_equipment_slot(-1, 1) + if next_index >= 0: + equipment_selection_index = next_index + selected_slot = equipment_slots_list[next_index] + selected_item = local_player.character_stats.equipment[selected_slot] if local_player and local_player.character_stats else null + _update_selection_from_navigation() + _update_selection_rectangle() + _update_info_panel() + return # Don't call _update_info_panel - we've set stat description above func _on_inventory_item_pressed(item: Item): @@ -1361,27 +1422,26 @@ func _input(event): if not is_open: return - # Arrow key navigation - if event is InputEventKey and event.pressed and not event.echo: - var direction = "" - if event.keycode == KEY_LEFT: - direction = "left" - elif event.keycode == KEY_RIGHT: - direction = "right" - elif event.keycode == KEY_UP: - direction = "up" - elif event.keycode == KEY_DOWN: - direction = "down" - - if direction != "": - if selected_type == "level_up_stat": - _navigate_level_up_stats(direction) - elif selected_type == "equipment": - _navigate_equipment(direction) - else: - _navigate_inventory(direction) - get_viewport().set_input_as_handled() - return + # Arrow key navigation (use ui_left/right/up/down so keybindings work) + var direction = "" + var skip_repeat = event is InputEventKey and event.echo + if not skip_repeat and event.is_action_pressed("ui_left"): + direction = "left" + elif not skip_repeat and event.is_action_pressed("ui_right"): + direction = "right" + elif not skip_repeat and event.is_action_pressed("ui_up"): + direction = "up" + elif not skip_repeat and event.is_action_pressed("ui_down"): + direction = "down" + if direction != "": + if selected_type == "level_up_stat": + _navigate_level_up_stats(direction) + elif selected_type == "equipment": + _navigate_equipment(direction) + else: + _navigate_inventory(direction) + get_viewport().set_input_as_handled() + return # F key: Unequip/equip items if event is InputEventKey and event.keycode == KEY_F and event.pressed and not event.echo: @@ -1408,6 +1468,8 @@ func _input(event): func _handle_f_key(): if not local_player or not local_player.character_stats: return + if not _can_use_inventory(): + return var char_stats = local_player.character_stats @@ -1567,6 +1629,8 @@ func _handle_f_key(): func _use_consumable_item(item: Item): if not local_player or not local_player.character_stats: return + if not _can_use_inventory(): + return var char_stats = local_player.character_stats @@ -1641,6 +1705,8 @@ func _use_consumable_item(item: Item): func _handle_e_key(): if not local_player or not local_player.character_stats: return + if not _can_use_inventory(): + return if selected_type != "item" or not selected_item: return diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 40bc284..7240e62 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -816,7 +816,7 @@ func _sync_show_floating_text(loot_type_value: int, text: String, color_value: C # Show floating text on client _show_floating_text(player, text, color_value, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame_value) -func _show_floating_text(player: Node, text: String, color: Color, show_time: float = 0.5, fade_time: float = 0.5, item_texture: Texture2D = null, sprite_hframes: int = 1, sprite_vframes: int = 1, sprite_frame: int = 0, item = null): +func _show_floating_text(player: Node, text: String, color: Color, show_time: float = 0.5, fade_time: float = 0.5, item_texture: Texture2D = null, sprite_hframes: int = 1, sprite_vframes: int = 1, sprite_frame: int = 0, item_param = null): # Create floating text and item graphic above player's head # Shows for show_time seconds, then fades out over fade_time seconds var floating_text_scene = preload("res://scenes/floating_text.tscn") @@ -826,4 +826,4 @@ func _show_floating_text(player: Node, text: String, color: Color, show_time: fl if parent: parent.add_child(floating_text) floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) - floating_text.setup(text, color, show_time, fade_time, item_texture, sprite_hframes, sprite_vframes, sprite_frame, item) + floating_text.setup(text, color, show_time, fade_time, item_texture, sprite_hframes, sprite_vframes, sprite_frame, item_param) diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 6b9f5eb..a905ba4 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -752,7 +752,7 @@ func _attempt_reconnect(): return # Check if we're already connected to Matchbox (host might have reconnected and we're waiting for peer ID) - if matchbox_client and matchbox_client.has("is_network_connected") and matchbox_client.is_network_connected: + if matchbox_client and ("is_network_connected" in matchbox_client) and matchbox_client.is_network_connected: log_print("NetworkManager: Already connected to Matchbox, waiting for host to assign peer ID...") # Cancel reconnection attempt - we're already connected, just waiting for host reconnection_attempting = false diff --git a/src/scripts/off_screen_indicators.gd b/src/scripts/off_screen_indicators.gd index 6496052..ee50d86 100644 --- a/src/scripts/off_screen_indicators.gd +++ b/src/scripts/off_screen_indicators.gd @@ -39,9 +39,10 @@ func _update_size() -> void: return var rect = vp.get_visible_rect() set_anchors_preset(Control.PRESET_FULL_RECT) - position = rect.position - size = rect.size - custom_minimum_size = rect.size + # Use set_deferred for size-related properties to avoid anchor override warnings + set_deferred("position", rect.position) + set_deferred("size", rect.size) + set_deferred("custom_minimum_size", rect.size) func _process(_delta: float) -> void: _indicators.clear() diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 17e5b24..4ad9e35 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -60,6 +60,8 @@ 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 struggle_time: float = 0.0 var struggle_threshold: float = 0.8 # Seconds to break free var struggle_direction: Vector2 = Vector2.ZERO @@ -136,6 +138,7 @@ const SPAWN_LANDING_STAND_DURATION: float = 0.16 # STAND anim duration (40+40+4 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 # Components # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites @@ -151,6 +154,11 @@ var spawn_landing_bounced: bool = false @onready var timer_walk = $SfxWalk/TimerWalk @onready var sfx_take_damage = $SfxTakeDamage @onready var sfx_die = $SfxDie +@onready var sfx_look_out = $SfxLookOut +@onready var sfx_ahaa = $SfxAhaa + +# Alert indicator (exclamation mark) +var alert_indicator: Sprite2D = null # Character sprite layers @onready var sprite_body = $Sprite2DBody @@ -353,19 +361,55 @@ func _ready(): # Add to player group for easy identification add_to_group("player") - # Spawn fall-down: hidden at start for everyone, then show and fall from high Z - visible = false - spawn_landing = true - spawn_landing_landed = false - spawn_landing_bounced = false - position_z = SPAWN_FALL_INITIAL_Z - velocity_z = 0.0 - is_airborne = true - if cone_light: - cone_light.visible = false - if point_light: - point_light.visible = false - call_deferred("_spawn_landing_show") + # Check if this is a joiner (player joining an already-started game) + # A joiner is identified by: there are other players with different peer_ids already in the game + # This distinguishes joiners from the initial host spawn + var is_joiner = false + for p in get_tree().get_nodes_in_group("player"): + if p != self and is_instance_valid(p): + # If there's another player with a different peer_id, this is a joiner + if "peer_id" in p and p.peer_id != peer_id: + is_joiner = true + break + + # Spawn: joiners get fall-from-sky but stay visible (no hide). Initial host spawn hides until fall. + if is_joiner: + # Joiners (local + remote): fall from sky, always visible. No hide/show – fixes invisible + attack bug. + visible = true + spawn_landing = true + spawn_landing_landed = false + spawn_landing_bounced = false + 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 + elif is_local_player: + # Local players (initial spawn only): hide until right before fall-from-sky + visible = false + spawn_landing = true + spawn_landing_landed = false + spawn_landing_bounced = false + spawn_landing_visible_shown = false + position_z = SPAWN_FALL_INITIAL_Z + velocity_z = 0.0 + is_airborne = true + if cone_light: + cone_light.visible = false + if point_light: + point_light.visible = false + call_deferred("_schedule_joiner_visibility_fallback") + else: + # Remote players that are NOT joiners: keep visible (they're already in game) + visible = true + spawn_landing = false + if cone_light: + cone_light.visible = false + if point_light: + point_light.visible = false # Set respawn point to starting position respawn_point = global_position @@ -399,6 +443,9 @@ func _ready(): if interaction_indicator: interaction_indicator.visible = false + # Set up alert indicator (exclamation mark) - similar to enemy humanoids + _setup_alert_indicator() + # Set up cone light blend mode, texture, initial rotation, and spread if cone_light: _update_cone_light_rotation() @@ -493,17 +540,16 @@ func _ready(): await get_tree().create_timer(check_interval).timeout waited += check_interval + var my_peer_id = multiplayer.get_unique_id() if all_players_spawned: # Wait a bit more after all players are spawned to ensure they're fully in scene tree await get_tree().create_timer(0.3).timeout - var my_peer_id = multiplayer.get_unique_id() game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send to server (peer 1) print("Player ", name, " (client) - notified server we're ready (all players spawned)") else: print("Player ", name, " (client) - timed out waiting for all players, notifying anyway") # Wait a bit even on timeout to ensure players are in scene tree await get_tree().create_timer(0.3).timeout - var my_peer_id = multiplayer.get_unique_id() game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send anyway after timeout can_send_rpcs = true @@ -515,10 +561,22 @@ func _ready(): # Emit character_changed to trigger appearance sync for any newly connected clients character_stats.character_changed.emit(character_stats) -func _spawn_landing_show(): - if not is_instance_valid(self): +func _schedule_joiner_visibility_fallback(): + if not is_instance_valid(self) or not is_inside_tree(): return - visible = true + get_tree().create_timer(2.0).timeout.connect(func(): + if not is_instance_valid(self) or not is_inside_tree(): + return + # Joiner must see self; if still hidden, force show (handles missed teleport/fall paths) + if is_local_player and not visible: + visible = true + spawn_landing_visible_shown = true + if cone_light: + cone_light.visible = true + if point_light: + point_light.visible = true + print(name, " visibility fallback: forced visible (joiner self)") + ) func _duplicate_sprite_materials(): # Duplicate shader materials for ALL sprites that use tint parameters @@ -1760,6 +1818,10 @@ func _update_z_physics(delta): var g = gravity_z if spawn_landing and not spawn_landing_landed: g = SPAWN_LANDING_BOUNCE_GRAVITY if spawn_landing_bounced else SPAWN_FALL_GRAVITY + # Show right before falling (local + joiner): was invisible until this moment + if not spawn_landing_visible_shown: + spawn_landing_visible_shown = true + visible = true velocity_z -= g * delta # Update Z position @@ -1893,6 +1955,10 @@ func _physics_process(delta): is_knocked_back = false knockback_time = 0.0 + # Handle enemy hand grab knockback timer + if grabbed_by_enemy_hand: + enemy_hand_grab_knockback_time += delta + # Update movement lock timer (for bow release) if movement_lock_timer > 0.0: movement_lock_timer -= delta @@ -1983,10 +2049,13 @@ func _physics_process(delta): # Skip input if controls are disabled (e.g., when inventory is open) or spawn landing (fall → DIE → stand up) # But still allow knockback to continue (handled above) + # CRITICAL: During entrance walk-out cut-scene, game_world sets velocity; do NOT zero it here + var entrance_walk_out = controls_disabled and has_meta("entrance_walk_target") var skip_input = controls_disabled or spawn_landing if controls_disabled or spawn_landing: - if not is_knocked_back: + if not is_knocked_back and not entrance_walk_out: # Immediately stop movement when controls are disabled (e.g., inventory opened) + # 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": @@ -2012,10 +2081,28 @@ func _physics_process(delta): # Handle struggle mechanic _handle_struggle(delta) elif grabbed_by_enemy_hand: - velocity = Vector2.ZERO is_shielding = false was_shielding_last_frame = false _update_shield_visibility() + + # 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 + 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 + + # 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 + var direction_to_hand = (target_pos - global_position).normalized() + velocity = direction_to_hand * 200.0 # Move speed toward hand + else: + # Close enough - snap to position and stop + global_position = target_pos + velocity = Vector2.ZERO elif is_knocked_back: is_shielding = false was_shielding_last_frame = false @@ -2276,7 +2363,11 @@ func _handle_input(): _update_cone_light_rotation() # Set animation based on state - if is_charging_spell: + if grabbed_by_enemy_hand: + # Keep FALL animation while grabbed by enemy hand + if current_animation != "FALL": + _set_animation("FALL") + elif is_charging_spell: # Use LIFT animation when charging spell and moving if current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("LIFT") @@ -2294,7 +2385,11 @@ func _handle_input(): _set_animation("RUN") else: # Idle animations - if is_charging_spell: + if grabbed_by_enemy_hand: + # Keep FALL animation while grabbed by enemy hand + if current_animation != "FALL": + _set_animation("FALL") + elif is_charging_spell: # Use CONJURE animation when charging spell and standing still if current_animation != "CONJURE" and current_animation != "FINISH_SPELL": _set_animation("CONJURE") @@ -2430,8 +2525,15 @@ func _handle_interactions(): shield_block_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized() if has_node("SfxActivateShield"): $SfxActivateShield.play() + # Sync shield up over network so host/other clients see it + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + _sync_shield.rpc(true, shield_block_direction) is_shielding = true else: + if was_shielding_last_frame: + # Sync shield down over network + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + _sync_shield.rpc(false, Vector2.ZERO) is_shielding = false was_shielding_last_frame = is_shielding _update_shield_visibility() @@ -2449,6 +2551,53 @@ func _handle_interactions(): print(name, " cancelled bow charge") + # Check for trap disarm FIRST (Dwarf only) - PRIORITY: disarm takes priority over spell casting + if character_stats and character_stats.race == "Dwarf": + var nearby_trap = _get_nearby_disarmable_trap() + if nearby_trap: + # Check if we're currently disarming this trap + var currently_disarming = (nearby_trap.disarming_player == self) + + if grab_just_pressed and not currently_disarming: + # Start disarming - cancel any spell charging + if is_charging_spell: + is_charging_spell = false + current_spell_element = "fire" + spell_incantation_played = false + _stop_spell_charge_particles() + _stop_spell_charge_incantation() + _clear_spell_charge_tint() + if has_node("SfxSpellCharge"): + $SfxSpellCharge.stop() + if has_node("SfxSpellIncantation"): + $SfxSpellIncantation.stop() + if multiplayer.has_multiplayer_peer(): + _sync_spell_charge_end.rpc() + print(name, " cancelled spell charge to start disarming") + + # Start disarming + is_disarming = true + nearby_trap.disarming_player = self + nearby_trap.disarm_progress = 0.0 + print(name, " (Dwarf) started disarming trap") + elif grab_just_released and currently_disarming: + # Cancel disarm if released early + is_disarming = false + nearby_trap._cancel_disarm() + print(name, " (Dwarf) cancelled disarm") + elif not currently_disarming: + # Not disarming anymore - reset flag + is_disarming = false + + # Don't process regular grab actions or spell casting if near trap + if grab_button_down: + # Skip grab handling and spell casting below + just_grabbed_this_frame = false + return + else: + # No nearby trap - reset disarming flag + is_disarming = false + # Check for spell casting (Tome of Flames, Frostspike, or Healing) if character_stats and character_stats.equipment.has("offhand"): var offhand_item = character_stats.equipment["offhand"] @@ -2467,6 +2616,7 @@ func _handle_interactions(): var has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) # Healing: allow charge even without target (don't disable charge when hovering enemy/wall/etc.) + # But prefer to have a target (player or enemy) when possible var can_start_charge = is_heal or has_valid_target # Check if there's a grabbable object nearby - prioritize grabbing over spell casting @@ -2582,37 +2732,6 @@ func _handle_interactions(): _sync_spell_charge_end.rpc() print(name, " spell charge cancelled (no target / lift / held)") - # Check for trap disarm (Dwarf only) - if character_stats and character_stats.race == "Dwarf": - var nearby_trap = _get_nearby_disarmable_trap() - if nearby_trap: - # Check if we're currently disarming this trap - var currently_disarming = (nearby_trap.disarming_player == self) - - if grab_just_pressed and not currently_disarming: - # Start disarming - is_disarming = true - nearby_trap.disarming_player = self - nearby_trap.disarm_progress = 0.0 - print(name, " (Dwarf) started disarming trap") - elif grab_just_released and currently_disarming: - # Cancel disarm if released early - is_disarming = false - nearby_trap._cancel_disarm() - print(name, " (Dwarf) cancelled disarm") - elif not currently_disarming: - # Not disarming anymore - reset flag - is_disarming = false - - # Don't process regular grab actions if near trap - if grab_button_down: - # Skip grab handling below - just_grabbed_this_frame = false - return - else: - # No nearby trap - reset disarming flag - is_disarming = false - # Check for bomb usage (if bomb equipped in offhand) # Also check if we're already holding a bomb - if so, skip normal grab handling var is_holding_bomb = false @@ -2655,27 +2774,34 @@ func _handle_interactions(): grab_start_time = Time.get_ticks_msec() / 1000.0 # Record grab time return else: - # Human/Elf: Place bomb directly - var game_world = get_tree().get_first_node_in_group("game_world") - var target_pos = Vector2.ZERO - if game_world and game_world.has_method("get_grid_locked_cursor_position"): - target_pos = game_world.get_grid_locked_cursor_position() + # Human/Elf: Throw bomb or drop next to player + # Consume one bomb + offhand_item.quantity -= 1 + var remaining = offhand_item.quantity + if offhand_item.quantity <= 0: + character_stats.equipment["offhand"] = null + if character_stats: + character_stats.character_changed.emit(character_stats) - if target_pos != Vector2.ZERO: - # Consume one bomb - offhand_item.quantity -= 1 - var remaining = offhand_item.quantity - if offhand_item.quantity <= 0: - character_stats.equipment["offhand"] = null - if character_stats: - character_stats.character_changed.emit(character_stats) - - # Place bomb - _place_bomb(target_pos) - - print(name, " used bomb! Remaining: ", remaining) - just_grabbed_this_frame = false - return + # Determine throw direction based on movement + var throw_direction = velocity.normalized() + var is_moving = throw_direction.length() > 0.1 + + if not is_moving: + # Not moving: use facing direction or last movement direction + if facing_direction_vector.length() > 0.1: + throw_direction = facing_direction_vector.normalized() + elif last_movement_direction.length() > 0.1: + throw_direction = last_movement_direction.normalized() + else: + throw_direction = Vector2.DOWN + + # Throw bomb in the direction (or drop next to player if not moving) + _throw_bomb_from_offhand(throw_direction, is_moving) + + print(name, " used bomb! Remaining: ", remaining) + just_grabbed_this_frame = false + return # If holding a bomb, skip normal grab press handling to prevent dropping it # But still allow grab release handling for the drop-on-second-press logic @@ -2856,7 +2982,7 @@ func _handle_interactions(): # Handle bow charging if has_bow_and_arrows and not is_lifting and not is_pushing: - if attack_just_pressed and can_attack and not is_charging_bow: + if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing: if !$SfxBuckleBow.playing: $SfxBuckleBow.play() # Start charging bow @@ -2923,8 +3049,8 @@ func _handle_interactions(): print(name, " bow charge cancelled (conditions changed)") # Normal attack (non-bow or no arrows) - # Also allow throwing when lifting (even if bow is equipped) - if attack_just_pressed and can_attack: + # Also allow throwing when lifting (even if bow is equipped). Block during spawn fall. + if attack_just_pressed and can_attack and not spawn_landing: if is_lifting: # Attack while lifting -> throw immediately in facing direction _force_throw_held_object(facing_direction_vector) @@ -2936,6 +3062,9 @@ func _handle_interactions(): func _get_nearby_disarmable_trap() -> Node: # Check for nearby trap that can be disarmed (Dwarf only) + # Use exact DisarmArea radius from trap scene (17.117243) + const DISARM_RANGE: float = 17.117243 + var traps = get_tree().get_nodes_in_group("trap") for trap in traps: if not trap or not is_instance_valid(trap): @@ -2943,10 +3072,21 @@ func _get_nearby_disarmable_trap() -> Node: # Check if trap is detected, not disarmed, and within disarm range if trap.is_detected and not trap.is_disarmed: - var distance = global_position.distance_to(trap.global_position) - # Check if within disarm area range (approximate - trap's DisarmArea has radius ~17) - if distance < 20: - return trap + # Check if player is actually inside the DisarmArea (more accurate than distance check) + if trap.has_node("DisarmArea"): + var disarm_area = trap.get_node("DisarmArea") + if disarm_area: + # Get overlapping bodies in DisarmArea + var bodies = disarm_area.get_overlapping_bodies() + for body in bodies: + if body == self: + # Player is inside DisarmArea - can disarm + return trap + else: + # Fallback: use distance check if DisarmArea not found + var distance = global_position.distance_to(trap.global_position) + if distance < DISARM_RANGE: + return trap return null @@ -3054,11 +3194,10 @@ func _try_grab(): closest_body.set_collision_mask_value(2, false) closest_body.set_collision_mask_value(7, true) # Keep wall collision elif _is_player(closest_body): - # Players are on layer 1 + # Players: remove from layer fully when lifted – no collision with anything closest_body.set_collision_layer_value(1, false) closest_body.set_collision_mask_value(1, false) - # IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions! - closest_body.set_collision_mask_value(7, true) # Enable collision with walls + closest_body.set_collision_mask_value(7, false) # When grabbing, immediately try to lift if possible _set_animation("IDLE") @@ -3394,8 +3533,7 @@ func _throw_object(): if thrown_obj.has_method("set_being_held"): thrown_obj.set_being_held(false) - # ⚡ Delay collision re-enable to prevent self-collision - await get_tree().create_timer(0.1).timeout + # Re-add to layer DIRECTLY when thrown (no delay) if thrown_obj and is_instance_valid(thrown_obj): thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_mask_value(1, true) @@ -3538,12 +3676,11 @@ func _force_throw_held_object(direction: Vector2): if thrown_obj.has_method("set_being_held"): thrown_obj.set_being_held(false) - # ⚡ Delay collision re-enable to prevent self-collision - await get_tree().create_timer(0.1).timeout + # Re-add to layer DIRECTLY when thrown (no delay) if thrown_obj and is_instance_valid(thrown_obj): thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_mask_value(1, true) - thrown_obj.set_collision_mask_value(7, true) # Re-enable wall collision! + thrown_obj.set_collision_mask_value(7, true) elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): # Other grabbable object - handle like box thrown_obj.global_position = throw_start_pos @@ -3598,6 +3735,10 @@ func _force_throw_held_object(direction: Vector2): # Bomb was converted to projectile (object was freed) print("Threw bomb (converted to projectile) from ", throw_start_pos, " with force: ", throw_direction * throw_force) +func _play_sfx_deny(): + if has_node("SfxDeny"): + $SfxDeny.play() + func _place_down_object(): if not held_object: return @@ -3608,6 +3749,7 @@ func _place_down_object(): # Dwarf dropping bomb: place attack_bomb with fuse lit (explodes if not picked up in time) if "object_type" in placed_obj and placed_obj.object_type == "Bomb": if not _can_place_down_at(place_pos, placed_obj): + _play_sfx_deny() return var bomb_name = placed_obj.name held_object = null @@ -3633,6 +3775,7 @@ func _place_down_object(): if not _can_place_down_at(place_pos, placed_obj): print("DEBUG: Place down blocked - space not free at ", place_pos) + _play_sfx_deny() return # Clear state @@ -3687,7 +3830,7 @@ func _place_down_object(): print("Placed down ", placed_obj.name, " at ", place_pos) func _perform_attack(): - if not can_attack or is_attacking: + if not can_attack or is_attacking or spawn_landing: return can_attack = false @@ -4001,8 +4144,62 @@ func _throw_bomb(_target_position: Vector2): # This is now unused for Dwarf but kept for compatibility pass +func _throw_bomb_from_offhand(throw_direction: Vector2, is_moving: bool): + # Human/Elf: Throw bomb if moving, or drop next to player if not moving + if not attack_bomb_scene: + return + + # Only authority can spawn bombs + if not is_multiplayer_authority(): + return + + var throw_force = Vector2.ZERO + var bomb_start_pos = global_position + + if is_moving: + # 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 + 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 + 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: + bomb_start_pos = found_pos + else: + # Fallback: just use position next to player + bomb_start_pos = drop_pos + else: + bomb_start_pos = drop_pos + # No throw force - bomb is dropped/placed + throw_force = Vector2.ZERO + + # Unique id for sync + var bomb_id = "Bomb_%d_%d" % [Time.get_ticks_msec(), get_multiplayer_authority()] + var bomb = attack_bomb_scene.instantiate() + bomb.name = bomb_id + get_parent().add_child(bomb) + bomb.global_position = bomb_start_pos + + # Set multiplayer authority + if multiplayer.has_multiplayer_peer(): + 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 + + # Sync bomb spawn to other clients + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_throw_bomb_from_offhand", [bomb_id, bomb_start_pos, throw_force, is_moving]) + + print(name, " " + ("threw" if is_moving else "dropped") + " bomb!") + func _place_bomb(target_position: Vector2): - # Human/Elf places bomb (no throw, just spawns at target) + # Human/Elf places bomb (no throw, just spawns at target) - DEPRECATED: Use _throw_bomb_from_offhand instead if not attack_bomb_scene: return @@ -4200,33 +4397,60 @@ func _cast_heal_spell(target: Node): var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() + + # Handle healing for players and enemies if is_revive: - if me == tid: - target._revive_from_heal(display_amount) - elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - target._revive_from_heal.rpc_id(tid, display_amount) + # Revive only works for players + if target.is_in_group("player"): + if me == tid: + target._revive_from_heal(display_amount) + elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + target._revive_from_heal.rpc_id(tid, display_amount) else: - if me == tid and actual_heal > 0: - target.heal(actual_heal, allow_overheal) + # Heal players or enemies + if actual_heal > 0: + if target.is_in_group("player"): + # Player healing + if me == tid: + target.heal(actual_heal, allow_overheal) + elif target.is_in_group("enemy"): + # Enemy healing - use character_stats.heal() directly + if target.character_stats: + if me == tid: + target.character_stats.heal(actual_heal, allow_overheal) + # Sync current_health for backwards compatibility + target.current_health = target.character_stats.hp + elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + # Sync enemy healing via RPC + target.rpc_heal_enemy.rpc_id(tid, actual_heal, allow_overheal) + + # Spawn healing effect and text on target (works for both players and enemies) _spawn_heal_effect_and_text(target, display_amount, is_crit, is_overheal, false) + + # Sync healing to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and gw and gw.has_method("_apply_heal_spell_sync"): _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, actual_heal, display_amount, is_crit, is_overheal, allow_overheal, is_revive, false]) - print(name, " cast heal on ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ", revive: ", is_revive, ")") + + var target_type = "enemy" if target.is_in_group("enemy") else "player" + print(name, " cast heal on ", target_type, " ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ", revive: ", is_revive, ")") func _spawn_heal_effect_and_text(target: Node, display_amount: int, is_crit: bool, is_overheal: bool, is_damage_to_enemy: bool = false): if not target or not is_instance_valid(target): return var game_world = get_tree().get_first_node_in_group("game_world") var entities = game_world.get_node_or_null("Entities") if game_world else null - var parent = entities if entities else target.get_parent() + # Parent effect as sibling of target so it always follows the target (player or enemy) + var parent = target.get_parent() if target.get_parent() else (entities if entities else null) if not parent: return if healing_effect_scene: var eff = healing_effect_scene.instantiate() parent.add_child(eff) - eff.global_position = target.global_position + var pos = target.global_position + eff.global_position = pos if eff.has_method("setup"): eff.setup(target) + eff.global_position = pos if is_damage_to_enemy: # Undead: enemy's take_damage already shows damage number; we only spawn effect return @@ -4274,6 +4498,8 @@ func _get_heal_target() -> Node: const HEAL_RANGE: float = 56.0 var best: Node = null var best_d: float = HEAL_RANGE + + # Check players first for p in get_tree().get_nodes_in_group("player"): if not is_instance_valid(p): continue @@ -4281,15 +4507,16 @@ func _get_heal_target() -> Node: if d < best_d: best_d = d best = p + + # Check ALL enemies (not just undead) - can heal regular enemies, damage undead for e in get_tree().get_nodes_in_group("enemy"): if not is_instance_valid(e) or ("is_dead" in e and e.is_dead): continue - if not ("is_undead" in e and e.is_undead): - continue var d = e.global_position.distance_to(mouse_world) if d < best_d: best_d = d best = e + return best func _can_cast_spell_at(target_position: Vector2) -> bool: @@ -5113,6 +5340,17 @@ func _sync_bow_charge_end(): _clear_bow_charge_tint() print(name, " (synced) ended charging bow") +@rpc("any_peer", "reliable") +func _sync_shield(shielding: bool, block_dir: Vector2): + # Sync shield up/down to other clients so host sees joiner's shield + if is_multiplayer_authority(): + return + is_shielding = shielding + was_shielding_last_frame = shielding + if shielding and block_dir.length() > 0.01: + shield_block_direction = block_dir.normalized() + _update_shield_visibility() + @rpc("any_peer", "reliable") func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2): # Sync Dwarf's lifted bomb spawn to other clients so they see it when held @@ -5159,9 +5397,23 @@ func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2): bomb.set_multiplayer_authority(get_multiplayer_authority()) print(name, " (synced) dropped bomb at ", place_pos) +@rpc("any_peer", "reliable") +func _sync_throw_bomb_from_offhand(bomb_id: String, bomb_pos: Vector2, throw_force: Vector2, is_thrown: bool): + # Sync bomb throw/drop from offhand to other clients (Human/Elf) + if not is_multiplayer_authority(): + if not attack_bomb_scene: + return + + var bomb = attack_bomb_scene.instantiate() + bomb.name = bomb_id + get_parent().add_child(bomb) + bomb.global_position = bomb_pos + bomb.setup(bomb_pos, self, throw_force, is_thrown) + print(name, " (synced) " + ("threw" if is_thrown else "dropped") + " bomb at ", bomb_pos) + @rpc("any_peer", "reliable") func _sync_place_bomb(bomb_id: String, target_pos: Vector2): - # Sync bomb placement to other clients (Human/Elf) + # Sync bomb placement to other clients (Human/Elf) - DEPRECATED: Use _sync_throw_bomb_from_offhand instead if not is_multiplayer_authority(): if not attack_bomb_scene: return @@ -5287,8 +5539,7 @@ func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_n print("Player is now airborne on client!") - # ⚡ Delay collision re-enable to prevent self-collision on clients - await get_tree().create_timer(0.1).timeout + # Re-add to layer DIRECTLY when thrown (no delay) if obj and is_instance_valid(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) @@ -5331,6 +5582,7 @@ func _sync_initial_grab(obj_name: String, _offset: Vector2): elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) + obj.set_collision_mask_value(7, false) print("Synced initial grab on client: ", obj_name) @@ -5380,6 +5632,7 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) + obj.set_collision_mask_value(7, false) if obj.has_method("set_being_held"): obj.set_being_held(true) else: @@ -5398,6 +5651,7 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) + obj.set_collision_mask_value(7, false) if obj.has_method("set_being_held"): obj.set_being_held(true) @@ -5522,8 +5776,21 @@ func _sync_teleport_position(new_pos: Vector2): global_position = new_pos # Reset velocity to prevent player from moving back to old position velocity = Vector2.ZERO + # Always place teleported player on ground (fixes joiner seeing host "in air" when host was mid-bounce on join) + 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" # 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) + visible = true + if is_local_player: + if cone_light: + cone_light.visible = true + if point_light: + point_light.visible = true print(name, " teleported to ", new_pos, " (synced from server, is_authority: ", is_multiplayer_authority(), ")") @rpc("any_peer", "unreliable") @@ -5642,6 +5909,7 @@ func _force_place_down(direction: Vector2): if not _can_place_down_at(place_pos, placed_obj): print("DEBUG: Forced place down blocked - space not free") + _play_sfx_deny() return # Clear state @@ -5712,11 +5980,26 @@ func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void: if not en: en = _find_node_by_name(gw, enemy_name) grabbed_by_enemy_hand = en if en and is_instance_valid(en) else null - velocity = Vector2.ZERO + + if grabbed_by_enemy_hand: + # Apply initial knockback toward the hand + var hand_pos = grabbed_by_enemy_hand.global_position + var direction_to_hand = (hand_pos - global_position).normalized() + + # Apply knockback velocity toward the hand + velocity = direction_to_hand * 200.0 # Moderate knockback speed + is_knocked_back = true + knockback_time = 0.0 + + # Play FALL animation when grabbed by enemy hand + _set_animation("FALL") + else: + velocity = Vector2.ZERO @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 func _find_node_by_name(node: Node, n: String) -> Node: if not node: @@ -5993,7 +6276,7 @@ func _die(): # Play DIE animation _set_animation("DIE") - # Sync death over network (only authority sends) + # Sync death over network (only authority sends). Replicas run _apply_death_visual only. if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_death", []) @@ -6032,23 +6315,42 @@ func _die(): being_held_by = null - # If another player is alive, lie dead until ALL players are dead (or we get revived) - while not _are_all_players_dead(): - await get_tree().create_timer(0.2).timeout - if was_revived: - return + # Replicas: no wait loop; we get _sync_respawn from authority. + if not is_multiplayer_authority(): + return - # Brief delay after last death before respawning - await get_tree().create_timer(0.5).timeout + # Authority: server-authoritative respawn. Only server decides "all dead", then signals. + # Avoids desync where only host or only joiner respawns (e.g. _sync_death not received). + var gw = get_tree().get_first_node_in_group("game_world") + if gw and gw.has_method("_register_player_died"): + gw._register_player_died(self) + + 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) + + while not respawn_requested[0] and not was_revived: + await get_tree().create_timer(0.2).timeout if was_revived: return - # Respawn (this will reset is_processing_death) _respawn() func _are_all_players_dead() -> bool: - for p in get_tree().get_nodes_in_group("player"): - if "is_dead" in p and not p.is_dead: + # Use PlayerManager.get_all_players() to avoid duplicates (e.g. same peer seen twice). + var gw = get_tree().get_first_node_in_group("game_world") + if not gw: + return true + var pm = gw.get_node_or_null("PlayerManager") + if not pm or not pm.has_method("get_all_players"): + # Fallback to group + for p in get_tree().get_nodes_in_group("player"): + if "is_dead" in p and not p.is_dead: + return false + return true + for p in pm.get_all_players(): + if is_instance_valid(p) and "is_dead" in p and not p.is_dead: return false return true @@ -6109,11 +6411,29 @@ func _spawn_landing_stand_up(): cone_light.visible = true if point_light: point_light.visible = true + + # Joiners: ensure all other players are visible once we've finished falling down + if is_local_player: + for p in get_tree().get_nodes_in_group("player"): + if p != self and is_instance_valid(p): + p.visible = true + + # Start background music when player finishes standing (only on authority to avoid duplicates) + if is_multiplayer_authority(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_start_bg_music"): + game_world._start_bg_music() func _respawn(): print(name, " respawning!") was_revived = false + # 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() + # being_held_by already cleared in _die() before this # Holder already dropped us 0.2 seconds ago @@ -6307,10 +6627,18 @@ func _revive_from_player(hp_amount: int): @rpc("any_peer", "reliable") func _sync_revived_clear_concussion(_player_name: String): - # Received on each peer's copy of the revived player; clear our concussion (server + clients) + # Received on each peer's copy of the revived player; sync revived state so game realizes we're alive. + is_dead = false + is_processing_death = false + for layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, + sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: + if layer: + layer.modulate.a = 1.0 var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") + _set_animation("IDLE") @rpc("any_peer", "reliable") func _revive_from_heal(hp_amount: int): @@ -6336,10 +6664,26 @@ func _revive_from_heal(hp_amount: int): if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) +func _apply_death_visual(): + # Replicas only: set death state + concussion + DIE anim. No coroutine — ensures other peer always sees concussion. + is_processing_death = true + is_dead = true + velocity = Vector2.ZERO + is_knocked_back = false + is_shielding = false + was_shielding_last_frame = false + _update_shield_visibility() + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("concussion"): + status_anim.play("concussion") + _set_animation("DIE") + @rpc("any_peer", "reliable") func _sync_death(): + # Replicas: apply death visuals only (concussion + DIE). No coroutine — ensures other peer always sees concussion. if not is_multiplayer_authority(): - _die() + _apply_death_visual() + return @rpc("any_peer", "reliable") func _sync_respawn(spawn_pos: Vector2): @@ -6351,11 +6695,23 @@ func _sync_respawn(spawn_pos: Vector2): set_collision_mask_value(1, true) set_collision_mask_value(7, true) # Re-enable wall collision! - # Just teleport and reset on clients (AFTER release is processed) - global_position = spawn_pos - current_health = max_health + # Reset health and state + if character_stats: + character_stats.hp = character_stats.maxhp + else: + current_health = max_health is_dead = false is_processing_death = false # Reset processing flag + velocity = Vector2.ZERO + is_knocked_back = false + is_airborne = false + damage_direction_lock_timer = 0.0 + position_z = 0.0 + velocity_z = 0.0 + + # Just teleport and reset on clients (AFTER release is processed) + global_position = spawn_pos + respawn_point = spawn_pos # Restore visibility for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, @@ -6365,11 +6721,17 @@ func _sync_respawn(spawn_pos: Vector2): sprite_layer.modulate.a = 1.0 # Clear concussion on clients (AnimationPlayerStatus -> idle) - var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") - if status_anim and status_anim.has_animation("idle"): - status_anim.play("idle") + var status_anim_node = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim_node and status_anim_node.has_animation("idle"): + status_anim_node.play("idle") + # Set animation to IDLE _set_animation("IDLE") + + # Hide GAME OVER screen on clients too + 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() func add_coins(amount: int): if character_stats: @@ -6440,23 +6802,12 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): var elf_ear_style = skin_index + 1 character_stats.setEars(elf_ear_style) - # Give Elf starting bow and arrows to remote players - # (Authority players get this in _setup_player_appearance) - # Check if equipment is missing - give it regardless of whether race changed + # Give Elf starting bow and arrows to remote players ONLY when slots are null (initial sync) + # Never overwrite existing equipment (e.g. shield, picked-up items) - preserves loadout across level transitions if not is_multiplayer_authority(): - var needs_equipment = false - if character_stats.equipment["mainhand"] == null or character_stats.equipment["offhand"] == null: - needs_equipment = true - else: - # Check if mainhand is not a bow or offhand is not arrows - var mainhand = character_stats.equipment["mainhand"] - var offhand = character_stats.equipment["offhand"] - if not mainhand or mainhand.item_name != "short_bow": - needs_equipment = true - elif not offhand or offhand.item_name != "arrow" or offhand.quantity < 3: - needs_equipment = true - - if needs_equipment: + var mainhand_empty = character_stats.equipment["mainhand"] == null + var offhand_empty = character_stats.equipment["offhand"] == null + if mainhand_empty and offhand_empty: var starting_bow = ItemDatabase.create_item("short_bow") var starting_arrows = ItemDatabase.create_item("arrow") if starting_bow and starting_arrows: @@ -6468,20 +6819,10 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): "Dwarf": character_stats.setEars(0) - # Give Dwarf starting bomb to remote players - # (Authority players get this in _setup_player_appearance) - # Check if equipment is missing - give it regardless of whether race changed + # Give Dwarf starting bombs 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(): - var needs_equipment = false if character_stats.equipment["offhand"] == null: - needs_equipment = true - else: - # Check if offhand is not a bomb - var offhand = character_stats.equipment["offhand"] - if not offhand or offhand.item_name != "Bomb" or offhand.quantity < 1: - needs_equipment = true - - if needs_equipment: var starting_bomb = ItemDatabase.create_item("bomb") if starting_bomb: starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start @@ -6491,25 +6832,12 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary): "Human": character_stats.setEars(0) - # Give Human (Wizard) starting spellbook (Tome of Flames) and Hat to remote players - # (Authority players get this in _setup_player_appearance) - # Check if equipment is missing - give it regardless of whether race changed + # Give Human (Wizard) starting tome 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 needs_equipment = false - if character_stats.equipment["offhand"] == null: - needs_equipment = true - else: - var offhand = character_stats.equipment["offhand"] - if not offhand or offhand.item_name != "Tome of Flames": - needs_equipment = true - if character_stats.equipment["headgear"] == null: - needs_equipment = true - else: - var headgear = character_stats.equipment["headgear"] - if not headgear or headgear.item_name != "Hat": - needs_equipment = true - - if needs_equipment: + var offhand_empty = character_stats.equipment["offhand"] == null + var headgear_empty = character_stats.equipment["headgear"] == null + if offhand_empty and headgear_empty: var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: character_stats.equipment["offhand"] = starting_tome @@ -6911,6 +7239,101 @@ func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = fa # Show damage number _show_damage_number(_amount, attacker_position, is_crit, is_miss, false, false) +func _setup_alert_indicator(): + # Create alert indicator (exclamation mark) similar to enemy humanoids + # Check if it already exists (in case it was added to scene) + alert_indicator = get_node_or_null("AlertIndicator") + if not alert_indicator: + # Create it programmatically + alert_indicator = Sprite2D.new() + alert_indicator.name = "AlertIndicator" + var exclamation_texture = load("res://assets/gfx/enemies/_utropstecken.png") + if exclamation_texture: + alert_indicator.texture = exclamation_texture + alert_indicator.hframes = 3 + alert_indicator.visible = false + alert_indicator.z_index = 100 + alert_indicator.position = Vector2(0, -20) + add_child(alert_indicator) + else: + push_error("Player: Could not load exclamation mark texture!") + alert_indicator = null + else: + # Ensure it's set up correctly + alert_indicator.visible = false + alert_indicator.z_index = 100 + if alert_indicator.position == Vector2.ZERO: + alert_indicator.position = Vector2(0, -20) + +func _show_alert_indicator(): + # Show exclamation mark above player head + if alert_indicator: + alert_indicator.visible = true + alert_indicator.frame = 0 + # Hide after 1.5 seconds + get_tree().create_timer(1.5).timeout.connect(func(): + if is_instance_valid(self) and alert_indicator: + alert_indicator.visible = false + ) + +func _on_trap_detected(): + # Called when player detects a trap + if not is_multiplayer_authority(): + return # Only authority triggers + + # Show exclamation mark + _show_alert_indicator() + + # Play sound locally + if sfx_look_out: + sfx_look_out.play() + + # Sync to all clients (so all players can hear it) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_trap_detected_alert", []) + +func _on_exit_found(): + # Called when player finds exit stairs + if not is_multiplayer_authority(): + return # Only authority triggers + + # Show exclamation mark + _show_alert_indicator() + + # Play sound locally + if sfx_ahaa: + sfx_ahaa.play() + + # Sync to all clients (so all players can hear it) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_exit_found_alert", []) + +@rpc("any_peer", "reliable") +func _sync_trap_detected_alert(): + # Sync trap detection alert to all clients + if is_multiplayer_authority(): + return # Authority already handled it locally + + # Show exclamation mark + _show_alert_indicator() + + # Play sound + if sfx_look_out: + sfx_look_out.play() + +@rpc("any_peer", "reliable") +func _sync_exit_found_alert(): + # Sync exit found alert to all clients + if is_multiplayer_authority(): + return # Authority already handled it locally + + # Show exclamation mark + _show_alert_indicator() + + # Play sound + if sfx_ahaa: + sfx_ahaa.play() + func on_grabbed(by_player): print(name, " grabbed by ", by_player.name) diff --git a/src/scripts/stairs.gd b/src/scripts/stairs.gd index 6c42a79..f98860b 100644 --- a/src/scripts/stairs.gd +++ b/src/scripts/stairs.gd @@ -31,18 +31,24 @@ 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() - # Only trigger on server/authority + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world: + print("Stairs: ERROR - Game world not found!") + return if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): - print("Stairs: Server detected, calling game_world") - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - print("Stairs: Game world found, calling _on_player_reached_stairs") - game_world._on_player_reached_stairs(body) - else: - print("Stairs: ERROR - Game world not found!") + print("Stairs: Server detected, calling _on_player_reached_stairs") + game_world._on_player_reached_stairs(body) else: - print("Stairs: Not server, ignoring") + # Client: notify server so level complete triggers (host may not detect joiner via sync) + var peer_id = body.get_multiplayer_authority() if body.has_method("get_multiplayer_authority") else 0 + if peer_id > 0 and game_world.has_method("_request_player_reached_stairs"): + print("Stairs: Client notifying server that player ", body.name, " (peer ", peer_id, ") reached stairs") + game_world._request_player_reached_stairs.rpc_id(1, peer_id) diff --git a/src/scripts/trap.gd b/src/scripts/trap.gd index 718421a..b8e8e7d 100644 --- a/src/scripts/trap.gd +++ b/src/scripts/trap.gd @@ -139,6 +139,10 @@ func _detect_trap(detecting_player: Node) -> void: # Make trap visible sprite.modulate.a = 1.0 + # Notify the detecting player to show alert and play sound + if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"): + detecting_player._on_trap_detected() + # Sync detection to all clients (including server with call_local) # CRITICAL: Validate trap is still valid before sending RPC # Use GameWorld RPC to avoid node path issues @@ -260,15 +264,18 @@ 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) - # Sync disarm to all clients - # CRITICAL: Validate trap is still valid before sending RPC - # Use GameWorld RPC to avoid node path issues + # Sync disarm to all clients (including host when joiner disarms) if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): - if multiplayer.is_server(): - # Use GameWorld RPC with trap name instead of path - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_trap_state_by_name"): - game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + if multiplayer.is_server(): + # Host disarmed: broadcast to clients + if game_world.has_method("_sync_trap_state_by_name"): + game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true + else: + # Joiner disarmed: request host to apply locally and broadcast to all + if game_world.has_method("_request_trap_disarm"): + game_world._request_trap_disarm.rpc_id(1, name) print("Trap disarmed!")