diff --git a/src/assets/audio/sfx/weapons/magic.mp3 b/src/assets/audio/sfx/weapons/magic.mp3 new file mode 100644 index 0000000..45e2eca Binary files /dev/null and b/src/assets/audio/sfx/weapons/magic.mp3 differ diff --git a/src/assets/audio/sfx/weapons/magic.mp3.import b/src/assets/audio/sfx/weapons/magic.mp3.import new file mode 100644 index 0000000..77e65d2 --- /dev/null +++ b/src/assets/audio/sfx/weapons/magic.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://cnq1rcdh6h34x" +path="res://.godot/imported/magic.mp3-f46332096bbc9033630ad74555859383.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/weapons/magic.mp3" +dest_files=["res://.godot/imported/magic.mp3-f46332096bbc9033630ad74555859383.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/audio/sfx/weapons/magic_impact.mp3 b/src/assets/audio/sfx/weapons/magic_impact.mp3 new file mode 100644 index 0000000..bd956e6 Binary files /dev/null and b/src/assets/audio/sfx/weapons/magic_impact.mp3 differ diff --git a/src/assets/audio/sfx/weapons/magic_impact.mp3.import b/src/assets/audio/sfx/weapons/magic_impact.mp3.import new file mode 100644 index 0000000..676a386 --- /dev/null +++ b/src/assets/audio/sfx/weapons/magic_impact.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://d3vo82fiyo076" +path="res://.godot/imported/magic_impact.mp3-a913f5b2e9946b900e1eb0911f0432da.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/weapons/magic_impact.mp3" +dest_files=["res://.godot/imported/magic_impact.mp3-a913f5b2e9946b900e1eb0911f0432da.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/audio/sfx/weapons/schioow.mp3 b/src/assets/audio/sfx/weapons/schioow.mp3 new file mode 100644 index 0000000..ce16900 Binary files /dev/null and b/src/assets/audio/sfx/weapons/schioow.mp3 differ diff --git a/src/assets/audio/sfx/weapons/schioow.mp3.import b/src/assets/audio/sfx/weapons/schioow.mp3.import new file mode 100644 index 0000000..fcb3b2c --- /dev/null +++ b/src/assets/audio/sfx/weapons/schioow.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://bvefpp6la7ehx" +path="res://.godot/imported/schioow.mp3-fc2506aa54189f71eeb0e728e8ae4aa4.mp3str" + +[deps] + +source_file="res://assets/audio/sfx/weapons/schioow.mp3" +dest_files=["res://.godot/imported/schioow.mp3-fc2506aa54189f71eeb0e728e8ae4aa4.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/assets/gfx/Puny-Characters/Layer 0 - Skins/Human4.png b/src/assets/gfx/Puny-Characters/Layer 0 - Skins/Human4.png index 243928d..9404858 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 0 - Skins/Human4.png and b/src/assets/gfx/Puny-Characters/Layer 0 - Skins/Human4.png differ diff --git a/src/assets/gfx/morph_ball.png b/src/assets/gfx/morph_ball.png new file mode 100644 index 0000000..b8483a6 Binary files /dev/null and b/src/assets/gfx/morph_ball.png differ diff --git a/src/assets/gfx/morph_ball.png.import b/src/assets/gfx/morph_ball.png.import new file mode 100644 index 0000000..69f904a --- /dev/null +++ b/src/assets/gfx/morph_ball.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2dya7wnmph5m" +path="res://.godot/imported/morph_ball.png-ca00582698de0ab8df351e786cd9023f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/morph_ball.png" +dest_files=["res://.godot/imported/morph_ball.png-ca00582698de0ab8df351e786cd9023f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/assets/gfx/props/torch_steel_01.png.import b/src/assets/gfx/props/torch_steel_01.png.import index a59b661..017ff74 100644 --- a/src/assets/gfx/props/torch_steel_01.png.import +++ b/src/assets/gfx/props/torch_steel_01.png.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://ndtcy3xo1uob" +uid="uid://bv2tcr2o1cov8" path="res://.godot/imported/torch_steel_01.png-1412ad992fcc159a1ee81cbd09810b38.ctex" metadata={ "vram_texture": false diff --git a/src/project.godot b/src/project.godot index f59de57..deaf0da 100644 --- a/src/project.godot +++ b/src/project.godot @@ -80,10 +80,11 @@ attack={ , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null) ] } - -[input_devices] - -pointing/emulate_touch_from_mouse=true +inventory={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/src/scenes/attack_axe_swing.tscn b/src/scenes/attack_axe_swing.tscn index bc2cb48..a61deb8 100644 --- a/src/scenes/attack_axe_swing.tscn +++ b/src/scenes/attack_axe_swing.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://tcobiw1iirdw"] [ext_resource type="Script" uid="uid://bqrtsr3mjvv3j" path="res://scripts/attack_axe_swing.gd" id="1_xo3v0"] -[ext_resource type="Texture2D" uid="uid://bwxpic53sluul" path="res://assets/gfx/sword_slash.png" id="2_lwt2c"] +[ext_resource type="Texture2D" uid="uid://dkpritx47nd4m" path="res://assets/gfx/pickups/items_n_shit.png" id="2_hb10f"] [ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_v2p0x"] [ext_resource type="AudioStream" uid="uid://uerx5rib87a6" path="res://assets/audio/sfx/weapons/bone_hit_wall_01.wav.mp3" id="4_ul7bj"] [ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="5_whqew"] @@ -52,7 +52,10 @@ y_sort_enabled = true script = ExtResource("1_xo3v0") [node name="Sprite2D" type="Sprite2D" parent="." unique_id=461038063] -texture = ExtResource("2_lwt2c") +texture = ExtResource("2_hb10f") +hframes = 20 +vframes = 14 +frame = 111 [node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=691292922] libraries/ = SubResource("AnimationLibrary_hj6i2") diff --git a/src/scenes/attack_spear_thrust.tscn b/src/scenes/attack_spear_thrust.tscn index c928081..20b873c 100644 --- a/src/scenes/attack_spear_thrust.tscn +++ b/src/scenes/attack_spear_thrust.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://b3my31y2ljai1"] [ext_resource type="Script" uid="uid://ddprn0wrasavr" path="res://scripts/attack_spear_thrust.gd" id="1_psi1x"] -[ext_resource type="Texture2D" uid="uid://bwxpic53sluul" path="res://assets/gfx/sword_slash.png" id="2_rh1o6"] +[ext_resource type="Texture2D" uid="uid://dkpritx47nd4m" path="res://assets/gfx/pickups/items_n_shit.png" id="2_d2i4u"] [ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="3_j7ui3"] [ext_resource type="AudioStream" uid="uid://uerx5rib87a6" path="res://assets/audio/sfx/weapons/bone_hit_wall_01.wav.mp3" id="4_cijfq"] [ext_resource type="AudioStream" uid="uid://dc7nt8gnjt5u5" path="res://assets/audio/sfx/weapons/melee_attack_12.wav.mp3" id="5_h4gub"] @@ -52,7 +52,11 @@ y_sort_enabled = true script = ExtResource("1_psi1x") [node name="Sprite2D" type="Sprite2D" parent="." unique_id=1051719548] -texture = ExtResource("2_rh1o6") +rotation = -2.3736477 +texture = ExtResource("2_d2i4u") +hframes = 20 +vframes = 14 +frame = 131 [node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=907474922] libraries/ = SubResource("AnimationLibrary_hj6i2") @@ -63,6 +67,7 @@ pitch_scale = 1.4 autoplay = true [node name="DamageArea" type="Area2D" parent="." unique_id=1687133888] +visible = false collision_layer = 0 collision_mask = 75 diff --git a/src/scenes/attack_staff.tscn b/src/scenes/attack_staff.tscn new file mode 100644 index 0000000..b250234 --- /dev/null +++ b/src/scenes/attack_staff.tscn @@ -0,0 +1,124 @@ +[gd_scene format=3 uid="uid://c8galam3n3p2r"] + +[ext_resource type="Script" uid="uid://bn5vp502u6pf5" path="res://scripts/staff_projectile.gd" id="1_projectile"] +[ext_resource type="Shader" uid="uid://ldl7vaq5n13f" path="res://shaders/cloth.gdshader" id="2_84jgf"] +[ext_resource type="Texture2D" uid="uid://2dya7wnmph5m" path="res://assets/gfx/morph_ball.png" id="2_rkc3p"] +[ext_resource type="AudioStream" uid="uid://bvefpp6la7ehx" path="res://assets/audio/sfx/weapons/schioow.mp3" id="3_84jgf"] +[ext_resource type="AudioStream" uid="uid://d3vo82fiyo076" path="res://assets/audio/sfx/weapons/magic_impact.mp3" id="4_rkc3p"] + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_qorvo"] +shader = ExtResource("2_84jgf") +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="RectangleShape2D" id="RectangleShape2D_projectile"] +size = Vector2(10, 12) + +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_qorvo"] +random_pitch = 1.0960649 +streams_count = 1 +stream_0/stream = ExtResource("3_84jgf") + +[sub_resource type="Animation" id="Animation_84jgf"] +length = 0.001 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0), +"transitions": PackedFloat32Array(1), +"update": 1, +"values": [6] +} + +[sub_resource type="Animation" id="Animation_qorvo"] +resource_name = "flying" +length = 0.4 +loop_mode = 1 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Sprite2D:frame") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.1, 0.2, 0.3), +"transitions": PackedFloat32Array(1, 1, 1, 1), +"update": 1, +"values": [6, 8, 7, 8] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_2q84p"] +_data = { +&"RESET": SubResource("Animation_84jgf"), +&"flying": SubResource("Animation_qorvo") +} + +[node name="StaffProjectile" type="Node2D" unique_id=357652786] +y_sort_enabled = true +script = ExtResource("1_projectile") + +[node name="Sprite2D" type="Sprite2D" parent="." unique_id=993464286] +material = SubResource("ShaderMaterial_qorvo") +scale = Vector2(0.75, 0.75) +texture = ExtResource("2_rkc3p") +hframes = 6 +vframes = 6 +frame = 6 + +[node name="Area2D" type="Area2D" parent="." unique_id=556563629] +collision_layer = 4 +collision_mask = 3 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D" unique_id=520125160] +position = Vector2(-0.25, 0) +shape = SubResource("RectangleShape2D_projectile") +debug_color = Color(0.70196074, 0, 0.09064378, 0.41960785) + +[node name="SfxSwosh" type="AudioStreamPlayer2D" parent="." unique_id=1006342490] +stream = SubResource("AudioStreamRandomizer_qorvo") +pitch_scale = 0.95 +max_distance = 983.0 +attenuation = 7.999991 +panning_strength = 1.1 +bus = &"Sfx" + +[node name="SfxImpact" type="AudioStreamPlayer2D" parent="." unique_id=1627987810] +stream = ExtResource("4_rkc3p") +volume_db = -0.282 +pitch_scale = 0.78 +max_distance = 983.0 +attenuation = 7.999991 +max_polyphony = 4 +panning_strength = 1.16 +bus = &"Sfx" + +[node name="SfxImpactWall" type="AudioStreamPlayer2D" parent="." unique_id=77775230] +stream = ExtResource("4_rkc3p") +volume_db = -4.0 +pitch_scale = 1.3 +max_distance = 951.0 +attenuation = 8.282115 +max_polyphony = 4 +panning_strength = 1.15 +bus = &"Sfx" + +[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=241170994] +libraries/ = SubResource("AnimationLibrary_2q84p") +autoplay = &"flying" diff --git a/src/scenes/enemy_humanoid.tscn b/src/scenes/enemy_humanoid.tscn index 85aa8fe..036cc1a 100644 --- a/src/scenes/enemy_humanoid.tscn +++ b/src/scenes/enemy_humanoid.tscn @@ -4,6 +4,7 @@ [ext_resource type="Script" uid="uid://cpxabh3uq1kl4" path="res://scripts/create_shadow_sprite.gd" id="2"] [ext_resource type="Texture2D" uid="uid://bkninujaqqvb1" path="res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1_1.png" id="3"] [ext_resource type="Texture2D" uid="uid://bloqx3mibftjn" path="res://assets/gfx/Puny-Characters/WeaponOverlayer.png" id="4"] +[ext_resource type="Shader" uid="uid://ldl7vaq5n13f" path="res://shaders/cloth.gdshader" id="4_r7ul0"] [ext_resource type="Texture2D" uid="uid://cwklipebg6eyp" path="res://assets/gfx/enemies/_utropstecken.png" id="5"] [ext_resource type="Texture2D" uid="uid://c4jkxpv3objot" path="res://assets/gfx/enemies/_questionmark.png" id="6"] [ext_resource type="AudioStream" uid="uid://dtydo3gymnrcv" path="res://assets/audio/sfx/enemies/goblin/die1.mp3" id="7_fikv0"] @@ -25,6 +26,168 @@ fill = 1 fill_from = Vector2(0.51304346, 0.46086955) fill_to = Vector2(0, 0) +[sub_resource type="ShaderMaterial" id="ShaderMaterial_uedn7"] +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="ShaderMaterial" id="ShaderMaterial_5x2ph"] +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="ShaderMaterial" id="ShaderMaterial_r7ul0"] +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="ShaderMaterial" id="ShaderMaterial_oynfq"] +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="ShaderMaterial" id="ShaderMaterial_b0veo"] +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="ShaderMaterial" id="ShaderMaterial_of8l8"] +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="ShaderMaterial" id="ShaderMaterial_ofeay"] +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="ShaderMaterial" id="ShaderMaterial_5a33a"] +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="ShaderMaterial" id="ShaderMaterial_i1636"] +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="CircleShape2D" id="CircleShape2D_1"] radius = 5.0 @@ -62,47 +225,56 @@ script = ExtResource("2") [node name="Sprite2DBody" type="Sprite2D" parent="." unique_id=855871821] y_sort_enabled = true +material = SubResource("ShaderMaterial_uedn7") texture = ExtResource("3") hframes = 35 vframes = 8 [node name="Sprite2DBoots" type="Sprite2D" parent="." unique_id=460958943] y_sort_enabled = true +material = SubResource("ShaderMaterial_5x2ph") hframes = 35 vframes = 8 [node name="Sprite2DArmour" type="Sprite2D" parent="." unique_id=6790482] y_sort_enabled = true +material = SubResource("ShaderMaterial_r7ul0") hframes = 35 vframes = 8 [node name="Sprite2DFacialHair" type="Sprite2D" parent="." unique_id=31110906] y_sort_enabled = true +material = SubResource("ShaderMaterial_oynfq") hframes = 35 vframes = 8 [node name="Sprite2DHair" type="Sprite2D" parent="." unique_id=425592986] y_sort_enabled = true +material = SubResource("ShaderMaterial_b0veo") hframes = 35 vframes = 8 [node name="Sprite2DEyes" type="Sprite2D" parent="." unique_id=496437887] y_sort_enabled = true +material = SubResource("ShaderMaterial_of8l8") hframes = 35 vframes = 8 [node name="Sprite2DEyeLashes" type="Sprite2D" parent="." unique_id=1799398723] y_sort_enabled = true +material = SubResource("ShaderMaterial_ofeay") hframes = 35 vframes = 8 [node name="Sprite2DAddons" type="Sprite2D" parent="." unique_id=1702763725] y_sort_enabled = true +material = SubResource("ShaderMaterial_5a33a") hframes = 35 vframes = 8 [node name="Sprite2DHeadgear" type="Sprite2D" parent="." unique_id=164186416] y_sort_enabled = true +material = SubResource("ShaderMaterial_i1636") hframes = 35 vframes = 8 diff --git a/src/scenes/game_world.tscn b/src/scenes/game_world.tscn index b7cf688..41516c4 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -24,7 +24,7 @@ z_index = -2 tile_set = ExtResource("9") [node name="TileMapLayerAbove" type="TileMapLayer" parent="Environment" unique_id=1234567892] -modulate = Color(1, 1, 1, 0.77254903) +modulate = Color(1, 1, 1, 0.46666667) z_index = 1 tile_set = ExtResource("9") diff --git a/src/scenes/ingame_hud.tscn b/src/scenes/ingame_hud.tscn index ab9d89b..2629e60 100644 --- a/src/scenes/ingame_hud.tscn +++ b/src/scenes/ingame_hud.tscn @@ -143,6 +143,34 @@ layout_mode = 2 theme = SubResource("Theme_standard_font") horizontal_alignment = 1 +[node name="VBoxContainerConnectionStatus" type="VBoxContainer" parent="UpperRight/HBoxContainer" unique_id=1933444960] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="LabelConnectionStatus" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerConnectionStatus" unique_id=1933444961] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "CONNECTION" +horizontal_alignment = 1 + +[node name="LabelMatchboxStatus" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerConnectionStatus" unique_id=1933444962] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "Matchbox: --" +horizontal_alignment = 1 + +[node name="LabelICEStatus" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerConnectionStatus" unique_id=1933444963] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "ICE: --" +horizontal_alignment = 1 + +[node name="LabelDataChannelsStatus" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerConnectionStatus" unique_id=1933444964] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "Data: --" +horizontal_alignment = 1 + [node name="VBoxContainerBoss" type="VBoxContainer" parent="UpperRight/HBoxContainer" unique_id=1933444957] layout_mode = 2 size_flags_horizontal = 3 diff --git a/src/scenes/loot.tscn b/src/scenes/loot.tscn index d8d8a16..8e8611d 100644 --- a/src/scenes/loot.tscn +++ b/src/scenes/loot.tscn @@ -3,6 +3,7 @@ [ext_resource type="Script" uid="uid://jk7o0itmiwp6" path="res://scripts/loot.gd" id="1"] [ext_resource type="Script" uid="uid://cpxabh3uq1kl4" path="res://scripts/create_shadow_sprite.gd" id="2"] [ext_resource type="AudioStream" uid="uid://b60bke4f5uw4v" path="res://assets/audio/sfx/pickups/coin_pickup.mp3" id="3_30m34"] +[ext_resource type="Texture2D" uid="uid://cimek2qjgoqa1" path="res://assets/gfx/pickups/gold_coin.png" id="3_531sv"] [ext_resource type="AudioStream" uid="uid://brl8ivwb1l5i7" path="res://assets/audio/sfx/pickups/coin_drop_01.wav.mp3" id="4_rtp8m"] [ext_resource type="AudioStream" uid="uid://umoxmryvbm01" path="res://assets/audio/sfx/cloth/leather_cloth_01.wav.mp3" id="5_vl55g"] [ext_resource type="AudioStream" uid="uid://d1qqsganlqnwh" path="res://assets/audio/sfx/pickups/key.mp3" id="6_gyjv8"] @@ -48,6 +49,8 @@ script = ExtResource("2") [node name="Sprite2D" type="Sprite2D" parent="." unique_id=1501367665] y_sort_enabled = true +texture = ExtResource("3_531sv") +hframes = 6 [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=265450649] shape = SubResource("CircleShape2D_2") diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 2975b29..1415ce6 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -2,6 +2,7 @@ [ext_resource type="Script" uid="uid://ck72vhkja7nbo" path="res://scripts/player.gd" id="1"] [ext_resource type="Script" uid="uid://cpxabh3uq1kl4" path="res://scripts/create_shadow_sprite.gd" id="3"] +[ext_resource type="Shader" uid="uid://ldl7vaq5n13f" path="res://shaders/cloth.gdshader" id="3_wnwbv"] [ext_resource type="Texture2D" uid="uid://bkninujaqqvb1" path="res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1_1.png" id="4"] [ext_resource type="Texture2D" uid="uid://dx1fovugabbwc" path="res://assets/gfx/Puny-Characters/Layer 1 - Shoes/IronBoots.png" id="5"] [ext_resource type="Texture2D" uid="uid://bbqk2lcs772q3" path="res://assets/gfx/Puny-Characters/Layer 2 - Clothes/Armour Body/BronzeArmour.png" id="6"] @@ -28,6 +29,8 @@ [ext_resource type="AudioStream" uid="uid://bdhmel5vyixng" path="res://assets/audio/sfx/player/take_damage/player_damaged_07.wav.mp3" id="26_gl8cc"] [ext_resource type="AudioStream" uid="uid://4vulahdsj4i2" path="res://assets/audio/sfx/swoosh/throw_01.wav.mp3" id="27_31cv2"] [ext_resource type="AudioStream" uid="uid://w6yon88kjfml" path="res://assets/audio/sfx/nickes/lift_sfx.ogg" id="28_pf23h"] +[ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="30_gl8cc"] +[ext_resource type="AudioStream" uid="uid://mbtpqlb5n3gd" path="res://assets/audio/sfx/weapons/bow/bow_no_arrow.mp3" id="31_487ah"] [sub_resource type="Gradient" id="Gradient_wqfne"] colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) @@ -52,19 +55,199 @@ fill_to = Vector2(0.8974359, 0.08547009) radius = 32.0 [sub_resource type="Gradient" id="Gradient_3v2ag"] -colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) +offsets = PackedFloat32Array(0.3883721, 0.8372093) +colors = PackedColorArray(0, 0, 0, 0.74509805, 0, 0, 0, 0) [sub_resource type="GradientTexture2D" id="GradientTexture2D_jej6c"] gradient = SubResource("Gradient_3v2ag") -width = 14 -height = 8 +width = 12 +height = 6 fill = 1 fill_from = Vector2(0.51304346, 0.46086955) fill_to = Vector2(0, 0) -[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_pf23h"] -radius = 3.0 -height = 12.0 +[sub_resource type="ShaderMaterial" id="ShaderMaterial_md1ol"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_bj30b"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_jc3p3"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_hax0n"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_t4otl"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_j2b1d"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_cs1tg"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_2dvfe"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_giy8y"] +shader = ExtResource("3_wnwbv") +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="ShaderMaterial" id="ShaderMaterial_fdfoy"] +shader = ExtResource("3_wnwbv") +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="CircleShape2D" id="CircleShape2D_wnwbv"] +radius = 4.0 [sub_resource type="CircleShape2D" id="CircleShape2D_2"] radius = 8.0 @@ -92,6 +275,11 @@ stream_4/stream = ExtResource("24_wqfne") stream_5/stream = ExtResource("25_wnwbv") stream_6/stream = ExtResource("26_gl8cc") +[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_hhpqf"] +random_pitch = 1.0630184 +streams_count = 1 +stream_0/stream = ExtResource("31_487ah") + [node name="Player" type="CharacterBody2D" unique_id=937429705] collision_mask = 67 motion_mode = 1 @@ -127,60 +315,71 @@ script = ExtResource("3") [node name="Sprite2DBody" type="Sprite2D" parent="." unique_id=2113577699] y_sort_enabled = true +material = SubResource("ShaderMaterial_md1ol") texture = ExtResource("4") hframes = 35 vframes = 8 [node name="Sprite2DBoots" type="Sprite2D" parent="." unique_id=598174931] y_sort_enabled = true +material = SubResource("ShaderMaterial_bj30b") texture = ExtResource("5") hframes = 35 vframes = 8 [node name="Sprite2DArmour" type="Sprite2D" parent="." unique_id=2130297502] y_sort_enabled = true +material = SubResource("ShaderMaterial_jc3p3") texture = ExtResource("6") hframes = 35 vframes = 8 [node name="Sprite2DFacialHair" type="Sprite2D" parent="." unique_id=1050766722] y_sort_enabled = true +material = SubResource("ShaderMaterial_hax0n") texture = ExtResource("7") hframes = 35 vframes = 8 [node name="Sprite2DHair" type="Sprite2D" parent="." unique_id=927492041] y_sort_enabled = true +material = SubResource("ShaderMaterial_t4otl") texture = ExtResource("8") hframes = 35 vframes = 8 [node name="Sprite2DEyes" type="Sprite2D" parent="." unique_id=2054421939] y_sort_enabled = true +material = SubResource("ShaderMaterial_j2b1d") texture = ExtResource("9") hframes = 35 vframes = 8 [node name="Sprite2DEyeLashes" type="Sprite2D" parent="." unique_id=1437938522] y_sort_enabled = true +material = SubResource("ShaderMaterial_cs1tg") texture = ExtResource("10") hframes = 35 vframes = 8 [node name="Sprite2DAddons" type="Sprite2D" parent="." unique_id=962307958] y_sort_enabled = true +material = SubResource("ShaderMaterial_2dvfe") texture = ExtResource("11") hframes = 35 vframes = 8 [node name="Sprite2DHeadgear" type="Sprite2D" parent="." unique_id=526463008] y_sort_enabled = true +material = SubResource("ShaderMaterial_giy8y") texture = ExtResource("12") hframes = 35 vframes = 8 [node name="Sprite2DWeapon" type="Sprite2D" parent="." unique_id=1889932388] +z_index = 1 y_sort_enabled = true +material = SubResource("ShaderMaterial_fdfoy") texture = ExtResource("13") hframes = 35 vframes = 8 @@ -188,7 +387,7 @@ vframes = 8 [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=989315141] position = Vector2(0, 4) rotation = -1.5707964 -shape = SubResource("CapsuleShape2D_pf23h") +shape = SubResource("CircleShape2D_wnwbv") [node name="GrabArea" type="Area2D" parent="." unique_id=518653365] position = Vector2(0, 4) @@ -256,3 +455,13 @@ energy = 0.13 blend_mode = 2 shadow_enabled = true max_distance = 100.0 + +[node name="SfxBowShoot" type="AudioStreamPlayer2D" parent="." unique_id=340970961] +stream = ExtResource("30_gl8cc") +pitch_scale = 1.33 +attenuation = 6.7271657 + +[node name="SfxBowWithoutArrow" type="AudioStreamPlayer2D" parent="." unique_id=189976587] +stream = SubResource("AudioStreamRandomizer_hhpqf") +max_distance = 1455.0 +attenuation = 7.4642572 diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 4c23abf..980d452 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -13,6 +13,7 @@ signal character_changed(char: CharacterStats) signal signal_drop_item(item: Item) var character_type: String = "enemy" +var race: String = "Human" # "Dwarf", "Elf", or "Human" @export var level: int = 1 @export var character_name: String = "" @export var xp: float = 0 @@ -27,7 +28,9 @@ var facial_hair_color:Color = Color.WHITE var hairstyle:String = "" var hair_color:Color = Color.WHITE var eyes:String = "" +var eye_color:Color = Color.WHITE var eye_lashes:String = "" +var eyelash_color:Color = Color.WHITE var add_on:String = "" var bonusmaxhp: float = 0.0 @@ -363,6 +366,7 @@ func save() -> Dictionary: "character_type": character_type, "character_name": character_name, + "race": race, # Save race "baseStats": baseStats, "hp": hp, @@ -385,6 +389,8 @@ func save() -> Dictionary: "facial_hair_color": facial_hair_color.to_html(true), "hair_color": hair_color.to_html(true), + "eye_color": eye_color.to_html(true), # Save eye color + "eyelash_color": eyelash_color.to_html(true), # Save eyelash color "inventory": saveInventory(), "equipment": saveEquipment() @@ -449,6 +455,12 @@ func load(iDic: Dictionary) -> void: facial_hair_color = Color(iDic.get("facial_hair_color")) if iDic.has("hair_color"): hair_color = Color(iDic.get("hair_color")) + if iDic.has("eye_color"): + eye_color = Color(iDic.get("eye_color")) + if iDic.has("eyelash_color"): + eyelash_color = Color(iDic.get("eyelash_color")) + if iDic.has("race"): + race = iDic.get("race") pass ' @@ -767,3 +779,15 @@ func setHairColor(iColor:Color): hair_color = iColor emit_signal("character_changed", self) pass +func setEyeColor(iColor: Color): + eye_color = iColor + emit_signal("character_changed", self) + pass +func setEyelashColor(iColor: Color): + eyelash_color = iColor + emit_signal("character_changed", self) + pass +func setRace(iRace: String): + race = iRace + emit_signal("character_changed", self) + pass diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index c0f9d98..159fcf3 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -764,6 +764,19 @@ func _place_torches_in_room(room: Dictionary, grid: Array, all_doors: Array, _ma # We need to check all valid Y positions on the left/right walls and place at torch_y_from_floor # Left/right walls are valid from room.y + 2 to room.y + room.h - 2 (skipping 2-tile corners) for y in range(room.y + 2, room.y + room.h - 2): + # First check if there's a door at this Y position on the left wall + # Left door (dir="W") is 3 tiles tall, so check if y is in any door's Y range + var has_door_at_y = false + for door in all_doors: + var door_dir = door.dir if "dir" in door else "" + if door_dir == "W" and door.x == room.x: + # Left door at room.x, check if y is within door's Y range (door.y to door.y + 3) + if y >= door.y and y <= door.y + 3: + has_door_at_y = true + break + if has_door_at_y: + continue # Skip this Y position if there's a door here + # Check if this is a valid left wall position # Left wall has 2 tiles: room.x and room.x + 1 # Check both tiles to ensure we're not placing on a door @@ -804,14 +817,18 @@ func _place_torches_in_room(room: Dictionary, grid: Array, all_doors: Array, _ma # Door position is at door.x, door.y (upper-left tile) # Door occupies tiles: x from door.x to door.x + 2, y from door.y to door.y + 3 # Door world bounding box: from (door.x * 16, door.y * 16) to ((door.x + 2) * 16, (door.y + 3) * 16) - var door_min_x = door.x * tile_size_check - var door_max_x = (door.x + 2) * tile_size_check - var door_min_y = door.y * tile_size_check - var door_max_y = (door.y + 3) * tile_size_check + # CRITICAL: A torch is 16x16 pixels (8px in each direction), so expand door bounds by 8px in ALL directions + # Door occupies columns room.x (0) and room.x + 1 (1), so torch at room.x + 1 can overlap if it extends left + var door_min_x = door.x * tile_size_check - 8 + var door_max_x = (door.x + 2) * tile_size_check + 8 + var door_min_y = door.y * tile_size_check - 8 + var door_max_y = (door.y + 3) * tile_size_check + 8 - # Check if torch bounding box overlaps with door bounding box - if not (torch_bbox_max_x < door_min_x or torch_bbox_min_x > door_max_x or \ - torch_bbox_max_y < door_min_y or torch_bbox_min_y > door_max_y): + # Check if torch bounding box overlaps with door bounding box (non-overlapping means torch is safe) + # Overlap exists if NOT (torch_max < door_min OR torch_min > door_max) + var x_overlap = not (torch_bbox_max_x < door_min_x or torch_bbox_min_x > door_max_x) + var y_overlap = not (torch_bbox_max_y < door_min_y or torch_bbox_min_y > door_max_y) + if x_overlap and y_overlap: overlaps_door = true break @@ -1823,17 +1840,26 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size # Check if room has a "switch_pillar" puzzle - if so, we MUST spawn at least 1 pillar var has_pillar_switch_puzzle = false + var matching_puzzle_room = null if room_puzzle_data.size() > 0: - for puzzle_room in room_puzzle_data.keys(): - # Compare rooms by values (x, y, w, h) - if puzzle_room.x == room.x and puzzle_room.y == room.y and \ - puzzle_room.w == room.w and puzzle_room.h == room.h: - var puzzle_info = room_puzzle_data[puzzle_room] - LogManager.log("DungeonGenerator: Checking room (" + str(room.x) + "," + str(room.y) + ") - puzzle_room (" + str(puzzle_room.x) + "," + str(puzzle_room.y) + ") puzzle_type: " + str(puzzle_info.type), LogManager.CATEGORY_DUNGEON) - if puzzle_info.type == "switch_pillar": - has_pillar_switch_puzzle = true - LogManager.log("DungeonGenerator: Room (" + str(room.x) + "," + str(room.y) + ") has pillar switch puzzle - will spawn at least 1 pillar", LogManager.CATEGORY_DUNGEON) + # Try direct lookup first (room dictionary as key) + if room_puzzle_data.has(room): + matching_puzzle_room = room + else: + # Fallback: find matching room by comparing values (x, y, w, h) + for puzzle_room in room_puzzle_data.keys(): + # Compare rooms by values (x, y, w, h) + if puzzle_room.x == room.x and puzzle_room.y == room.y and \ + puzzle_room.w == room.w and puzzle_room.h == room.h: + matching_puzzle_room = puzzle_room break + + if matching_puzzle_room != null: + var puzzle_info = room_puzzle_data[matching_puzzle_room] + LogManager.log("DungeonGenerator: Checking room (" + str(room.x) + "," + str(room.y) + ") - puzzle_room (" + str(matching_puzzle_room.x) + "," + str(matching_puzzle_room.y) + ") puzzle_type: " + str(puzzle_info.type), LogManager.CATEGORY_DUNGEON) + if puzzle_info.type == "switch_pillar": + has_pillar_switch_puzzle = true + LogManager.log("DungeonGenerator: Room (" + str(room.x) + "," + str(room.y) + ") has pillar switch puzzle - will spawn at least 1 pillar", LogManager.CATEGORY_DUNGEON) else: LogManager.log("DungeonGenerator: room_puzzle_data is empty for room (" + str(room.x) + "," + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) @@ -1905,8 +1931,33 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size # Early return if no valid positions (unless pillar is required, but that's handled below) if valid_positions.size() == 0: if has_pillar_switch_puzzle: - push_warning("DungeonGenerator: Room (", room.x, ",", room.y, ") has pillar switch puzzle but NO valid positions! Cannot place pillar.") - return objects + # CRITICAL: Pillar is REQUIRED, so we must find at least one position + # Try a more permissive search - use ALL floor tiles in the room (even if near doors/walls) + LogManager.log("DungeonGenerator: Room (" + str(room.x) + "," + str(room.y) + ") has pillar switch puzzle but no valid positions. Trying fallback search...", LogManager.CATEGORY_DUNGEON) + # Use same bounds but skip the position validation check + var found_fallback = false + for x in range(min_x, max_x + 1): + for y in range(min_y, max_y + 1): + if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y: + if grid[x][y] == 1: # Floor + var world_x = x * tile_size + 8 + var world_y = y * tile_size + 8 + var world_pos = Vector2(world_x, world_y) + valid_positions.append(world_pos) + # Only need one position for the required pillar + found_fallback = true + break + if found_fallback: + break + + if valid_positions.size() == 0: + push_warning("DungeonGenerator: Room (", room.x, ",", room.y, ") has pillar switch puzzle but NO floor tiles! Cannot place pillar.") + return objects + else: + LogManager.log("DungeonGenerator: Found fallback position for required pillar in room (" + str(room.x) + "," + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) + else: + # No pillar required, safe to return + return objects # Shuffle positions to randomize placement valid_positions.shuffle() diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index 0560ec8..6d8ff02 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -341,7 +341,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals is_knocked_back = true knockback_time = 0.0 - _on_take_damage() + _on_take_damage(from_position) # Flash red (even if dying, show the hit) _flash_damage() @@ -444,8 +444,9 @@ func _update_client_visuals(): shadow.scale = Vector2.ONE * max(0.3, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 50.0) * 0.2 -func _on_take_damage(): +func _on_take_damage(_attacker_position: Vector2 = Vector2.ZERO): # Override in subclasses for custom damage reactions + # attacker_position is the position of the attacker (for facing logic) pass func _notify_doors_enemy_died(): @@ -548,81 +549,214 @@ func _spawn_loot(): LogManager.log_error(str(name) + " ERROR: loot_scene is null!", LogManager.CATEGORY_ENEMY) return - # Random chance to drop loot (70% chance) + # Get killer's LCK stat to influence loot drops + var killer_lck = 10.0 # Default LCK if no killer + if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: + killer_lck = killer_player.character_stats.baseStats.lck + killer_player.character_stats.get_pass("lck") + LogManager.log(str(name) + " killed by " + str(killer_player.name) + " with LCK: " + str(killer_lck), LogManager.CATEGORY_ENEMY) + + # Random chance to drop loot (85% chance - increased from 70%) + # LCK can increase this: +0.01% per LCK point (capped at 95%) + var base_loot_chance = 0.85 + var lck_bonus = min(killer_lck * 0.001, 0.1) # +0.1% per LCK, max +10% (95% cap) var loot_chance = randf() - LogManager.log(str(name) + " loot chance roll: " + str(loot_chance) + " (need > 0.3)", LogManager.CATEGORY_ENEMY) - if loot_chance > 0.3: - # Decide what to drop: 30% coin, 30% food, 40% item - var drop_roll = randf() - var loot_type = 0 - var drop_item = false - - if drop_roll < 0.3: - # 30% chance for coin - loot_type = 0 # COIN - elif drop_roll < 0.6: - # 30% chance for food item - var food_types = [1, 2, 3] # APPLE, BANANA, CHERRY - loot_type = food_types[randi() % food_types.size()] + var loot_threshold = 1.0 - (base_loot_chance + lck_bonus) + LogManager.log(str(name) + " loot chance roll: " + str(loot_chance) + " (need > " + str(loot_threshold) + ", base=" + str(base_loot_chance) + ", LCK bonus=" + str(lck_bonus) + ")", LogManager.CATEGORY_ENEMY) + if loot_chance > loot_threshold: + # Determine how many loot items to drop (1-4 items, influenced by LCK) + # Base: 1-3 items, LCK can push towards 2-4 items + # LCK effect: Each 5 points of LCK above 10 increases chance for extra drops + var lck_modifier = (killer_lck - 10.0) / 5.0 # +1 per 5 LCK above 10 + var num_drops_roll = randf() + var base_num_drops_roll = num_drops_roll - (lck_modifier * 0.1) # LCK reduces roll needed (up to -0.6 at LCK 40) + var num_drops = 1 + if base_num_drops_roll < 0.5: + num_drops = 1 # 50% base chance for 1 item (reduced from 60%) + elif base_num_drops_roll < 0.8: + num_drops = 2 # 30% base chance for 2 items + elif base_num_drops_roll < 0.95: + num_drops = 3 # 15% base chance for 3 items else: - # 40% chance for Item instance - drop_item = true + num_drops = 4 # 5% base chance for 4 items (LCK makes this more likely) - # Generate random velocity values (same on all clients) - var random_angle = randf() * PI * 2 - var random_force = randf_range(50.0, 100.0) - var random_velocity_z = randf_range(80.0, 120.0) - - # Generate initial velocity (same on all clients via RPC) - var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force + # Ensure at least 1 drop + num_drops = max(1, num_drops) + LogManager.log(str(name) + " spawning " + str(num_drops) + " loot item(s) (LCK modifier: " + str(lck_modifier) + ")", LogManager.CATEGORY_ENEMY) # Find safe spawn position (on floor tile, not in walls) var game_world = get_tree().get_first_node_in_group("game_world") - var safe_spawn_pos = global_position + var base_spawn_pos = global_position if game_world and game_world.has_method("_find_nearby_safe_spawn_position"): - safe_spawn_pos = game_world._find_nearby_safe_spawn_position(global_position, 64.0) + base_spawn_pos = game_world._find_nearby_safe_spawn_position(global_position, 64.0) var entities_node = get_parent() if not entities_node: LogManager.log_error(str(name) + " ERROR: entities_node is null! Cannot spawn loot!", LogManager.CATEGORY_ENEMY) return - if drop_item: - # Spawn Item instance as loot - var item = ItemDatabase.get_random_enemy_drop() - if item: - ItemLootHelper.spawn_item_loot(item, global_position, entities_node, game_world) - LogManager.log(str(name) + " ✓ dropped item: " + str(item.item_name) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY) - else: - # Spawn regular loot (coin or food) - var loot = loot_scene.instantiate() - entities_node.add_child(loot) - loot.global_position = safe_spawn_pos - loot.loot_type = loot_type - # Set initial velocity before _ready() processes - loot.velocity = initial_velocity - loot.velocity_z = random_velocity_z - loot.velocity_set_by_spawner = true - loot.is_airborne = true - LogManager.log(str(name) + " ✓ dropped loot: " + str(loot_type) + " at " + str(safe_spawn_pos) + " (original enemy pos: " + str(global_position) + ")", LogManager.CATEGORY_ENEMY) + # Spawn multiple loot items + for i in range(num_drops): + # Decide what to drop for this item, influenced by LCK + # LCK makes better items more likely: reduces coin chance, increases item chance + var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+ + var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+ - # Sync loot spawn to all clients (use safe position) - if multiplayer.has_multiplayer_peer(): - # Reuse game_world variable from above - if game_world: - # Generate unique loot ID - # loot_id_counter is declared as a variable in game_world.gd, so it always exists - var loot_id = game_world.loot_id_counter - game_world.loot_id_counter += 1 - # Store loot ID on server loot instance - loot.set_meta("loot_id", loot_id) - # Sync to clients with ID - game_world._rpc_to_ready_peers("_sync_loot_spawn", [safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id]) - LogManager.log(str(name) + " ✓ synced loot spawn to clients", LogManager.CATEGORY_ENEMY) + # Base probabilities: 50% coin, 20% food, 30% item + var coin_chance = 0.5 - lck_penalty_coin + var food_chance = 0.2 + var item_chance = 0.3 + lck_bonus_item + + # Normalize probabilities + var total = coin_chance + food_chance + item_chance + coin_chance /= total + food_chance /= total + item_chance /= total + + var drop_roll = randf() + var loot_type = 0 + var drop_item = false + var item_rarity_boost = false # LCK can boost item rarity + + if drop_roll < coin_chance: + # Coin + loot_type = 0 # COIN + elif drop_roll < coin_chance + food_chance: + # Food item + var food_types = [1, 2, 3] # APPLE, BANANA, CHERRY + loot_type = food_types[randi() % food_types.size()] + else: + # Item instance - LCK can boost rarity + drop_item = true + # Higher LCK = better chance for rarer items + item_rarity_boost = killer_lck > 15.0 + + # Generate deterministic random velocity values using dungeon seed + # This ensures loot bounces the same on all clients + var loot_rng = RandomNumberGenerator.new() + # game_world is already declared above (line 587) + var base_seed = 0 + if game_world and "dungeon_seed" in game_world: + base_seed = game_world.dungeon_seed + + # Get loot_id first (needed for seed calculation to ensure determinism) + var loot_id = 0 + if game_world: + # Try to get loot_id_counter (it's always declared in game_world.gd) + # Access it directly - if it doesn't exist, we'll use fallback + var loot_counter = game_world.get("loot_id_counter") + if loot_counter != null: + loot_id = loot_counter else: - LogManager.log_error(str(name) + " ERROR: game_world not found for loot sync!", LogManager.CATEGORY_ENEMY) + # Fallback: use enemy_index + loot_index for deterministic ID + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else 0 + loot_id = enemy_index * 1000 + i + else: + # Fallback: use enemy_index + loot_index for deterministic ID + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else 0 + loot_id = enemy_index * 1000 + i + + # Create unique seed for this loot item: dungeon_seed + loot_id + # This ensures each loot item gets a unique but deterministic seed + var loot_seed = base_seed + loot_id + 10000 # Offset to avoid collisions + loot_rng.seed = loot_seed + + var random_angle = loot_rng.randf() * PI * 2 + var random_force = loot_rng.randf_range(50.0, 100.0) + var random_velocity_z = loot_rng.randf_range(80.0, 120.0) + + # Generate initial velocity (same on all clients via RPC) + var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force + + # Slightly offset position for multiple items (spread them out) + var spawn_offset = Vector2(cos(random_angle), sin(random_angle)) * loot_rng.randf_range(10.0, 30.0) + var safe_spawn_pos = base_spawn_pos + spawn_offset + if game_world and game_world.has_method("_find_nearby_safe_spawn_position"): + safe_spawn_pos = game_world._find_nearby_safe_spawn_position(safe_spawn_pos, 32.0) + + if drop_item: + # Spawn Item instance as loot - LCK influences rarity + var item = null + if item_rarity_boost: + # High LCK: use chest rarity weights (better loot) instead of enemy drop weights + # Roll for rarity with LCK bonus: each 5 LCK above 15 increases rare/epic chance + var rarity_roll = randf() + var lck_rarity_bonus = min((killer_lck - 15.0) * 0.02, 0.15) # Up to +15% rare/epic chance + + # Clamp values to prevent going below 0 or above 1 + var common_threshold = max(0.0, 0.3 - lck_rarity_bonus) + var uncommon_threshold = max(common_threshold, 0.65 - (lck_rarity_bonus * 0.5)) + var rare_threshold = min(1.0, 0.90 + (lck_rarity_bonus * 2.0)) + + if rarity_roll < common_threshold: + # Common (reduced by LCK) + item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.COMMON) + elif rarity_roll < uncommon_threshold: + # Uncommon (slightly reduced) + item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.UNCOMMON) + elif rarity_roll < rare_threshold: + # Rare (increased by LCK) + item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.RARE) + else: + # Epic/Consumable (greatly increased by LCK) + var epic_roll = randf() + if epic_roll < 0.5: + item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.EPIC) + else: + item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.CONSUMABLE) + else: + # Normal LCK: use standard enemy drop weights + item = ItemDatabase.get_random_enemy_drop() + + if item: + ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world) + LogManager.log(str(name) + " ✓ dropped item #" + str(i+1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY) + else: + # Spawn regular loot (coin or food) + var loot = loot_scene.instantiate() + entities_node.add_child(loot) + loot.global_position = safe_spawn_pos + loot.loot_type = loot_type + # Set initial velocity before _ready() processes + loot.velocity = initial_velocity + loot.velocity_z = random_velocity_z + loot.velocity_set_by_spawner = true + loot.is_airborne = true + LogManager.log(str(name) + " ✓ dropped loot #" + str(i+1) + ": " + str(loot_type) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY) + + # Sync loot spawn to all clients (use safe position) + if multiplayer.has_multiplayer_peer(): + # Reuse game_world variable from above + if game_world: + # Use the loot_id we already calculated (or get real one if we used fallback) + # loot_id_counter is declared as a variable in game_world.gd, so it always exists + if loot_id == 0: + # We used fallback, get real ID now + loot_id = game_world.loot_id_counter + game_world.loot_id_counter += 1 + # Recalculate seed with real loot_id + var real_loot_seed = base_seed + loot_id + 10000 + loot_rng.seed = real_loot_seed + # Regenerate velocity with correct seed + var real_random_angle = loot_rng.randf() * PI * 2 + var real_random_force = loot_rng.randf_range(50.0, 100.0) + var real_random_velocity_z = loot_rng.randf_range(80.0, 120.0) + initial_velocity = Vector2(cos(real_random_angle), sin(real_random_angle)) * real_random_force + random_velocity_z = real_random_velocity_z + # Update loot with correct velocity + loot.velocity = initial_velocity + loot.velocity_z = random_velocity_z + else: + # We already have the correct loot_id, just increment counter + game_world.loot_id_counter += 1 + # Store loot ID on server loot instance + loot.set_meta("loot_id", loot_id) + # Sync to clients with ID + game_world._rpc_to_ready_peers("_sync_loot_spawn", [safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id]) + LogManager.log(str(name) + " ✓ synced loot #" + str(i+1) + " spawn to clients", LogManager.CATEGORY_ENEMY) + else: + LogManager.log_error(str(name) + " ERROR: game_world not found for loot sync!", LogManager.CATEGORY_ENEMY) else: - LogManager.log(str(name) + " loot chance failed (" + str(loot_chance) + " <= 0.3), no loot dropped", LogManager.CATEGORY_ENEMY) + LogManager.log(str(name) + " loot chance failed (" + str(loot_chance) + " <= 0.15), no loot dropped", LogManager.CATEGORY_ENEMY) # This function can be called directly (not just via RPC) when game_world routes the update 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): @@ -648,7 +782,8 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0 current_direction = dir as Direction # Update state if provided (for enemies with state machines like bats/slimes) - if state_value != -1 and "state" in self: + # CRITICAL: Don't update state if enemy is dead - this prevents overriding DYING state + if state_value != -1 and "state" in self and not is_dead: set("state", state_value) # Update animation if provided (for humanoid enemies with player-like animation system) @@ -670,9 +805,15 @@ func _sync_damage_visual(damage_amount: float = 0.0, attacker_position: Vector2 if is_multiplayer_authority(): return # Server ignores its own updates + # CRITICAL: Don't play damage animation if enemy is already dead + # This prevents damage sync from overriding death animation (e.g., if packets arrive out of order) + if is_dead: + LogManager.log(str(name) + " (client) ignoring damage visual sync - already dead", LogManager.CATEGORY_ENEMY) + return + # Trigger damage animation and state change on client # This ensures clients play the damage animation (e.g., slime DAMAGE animation) - _on_take_damage() + _on_take_damage(attacker_position) _flash_damage() @@ -695,6 +836,47 @@ func _sync_death(): # This matches what happens on the server when rats/slimes die set_collision_layer_value(2, false) # Remove from enemy collision layer (layer 2) + # CRITICAL: For state-based enemies (like slimes), set state to DYING before setting animation + # This ensures _update_client_visuals doesn't override the DIE animation with DAMAGE + # Check if enemy has a state variable - if so, try to set it to DYING + # For slimes: SlimeState.DYING = 4 + # This prevents _update_client_visuals from seeing DAMAGED state and setting DAMAGE animation + if "state" in self: + var current_state = get("state") + # SlimeState enum: IDLE=0, MOVING=1, JUMPING=2, DAMAGED=3, DYING=4 + # Set state to DYING (4) if it's currently DAMAGED (3) or less + if current_state <= 3: # DAMAGED or less + set("state", 4) # Set to DYING + LogManager.log(str(name) + " (client) set state to DYING (4) in _sync_death to override DAMAGED state", LogManager.CATEGORY_ENEMY) + + # For humanoid enemies, ensure death animation is set immediately and animation state is reset + # This is critical for joiner clients who receive death sync + if has_method("_set_animation"): + LogManager.log(str(name) + " (client) setting DIE animation in _sync_death", LogManager.CATEGORY_ENEMY) + _set_animation("DIE") + # Also ensure animation frame is reset and animation system is ready + if "current_frame" in self: + set("current_frame", 0) + LogManager.log(str(name) + " (client) reset current_frame to 0", LogManager.CATEGORY_ENEMY) + if "time_since_last_frame" in self: + set("time_since_last_frame", 0.0) + LogManager.log(str(name) + " (client) reset time_since_last_frame to 0.0", LogManager.CATEGORY_ENEMY) + # Verify animation was set + if "current_animation" in self: + var anim_name = get("current_animation") + LogManager.log(str(name) + " (client) current_animation after _set_animation: " + str(anim_name), LogManager.CATEGORY_ENEMY) + + # CRITICAL: Force immediate animation update for humanoid enemies + # This ensures DIE animation is visible immediately on clients + if has_method("_update_animation") and "current_animation" in self: + call("_update_animation", 0.0) + LogManager.log(str(name) + " (client) forced immediate _update_animation(0.0) after setting DIE in _sync_death", LogManager.CATEGORY_ENEMY) + + # CRITICAL: Call _update_client_visuals immediately to ensure DIE animation is applied + # This prevents _update_client_visuals from running later and overriding with DAMAGE + if has_method("_update_client_visuals"): + _update_client_visuals() + # Immediately mark as dead and stop AI/physics # This prevents "inactive" enemies that are already dead _play_death_animation() diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index 3084d9b..b03ed33 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -269,20 +269,41 @@ func _randomize_appearance(): var ear_type = appearance_rng.randi_range(0, 7) _set_ears(ear_type) - # Randomize hair color (bright colors for enemies) + # Randomize hair color - vibrant and weird colors! (same as players) var hair_colors = [ - Color.WHITE, Color(0.9, 0.9, 0.9), Color(0.7, 0.7, 0.7), # White/Gray - Color(0.5, 0.3, 0.2), Color(0.3, 0.2, 0.1), # Brown/Black - Color(0.9, 0.7, 0.4), Color(0.8, 0.6, 0.3) # Blonde + Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), # Brown + Color(0.8, 0.6, 0.4), # Blonde + Color(0.6, 0.3, 0.1), # Dark brown + Color(0.9, 0.7, 0.5), # Light blonde + Color(0.2, 0.2, 0.2), # Dark gray + Color(0.5, 0.5, 0.5), # Gray + Color(0.5, 0.8, 0.2), # Snot green + Color(0.9, 0.5, 0.1), # Orange + Color(0.8, 0.3, 0.9), # Purple + Color(1.0, 0.9, 0.2), # Yellow + Color(1.0, 0.5, 0.8), # Pink + Color(0.9, 0.2, 0.2), # Red + Color(0.2, 0.9, 0.9), # Bright cyan + Color(0.6, 0.2, 0.9), # Magenta + Color(0.9, 0.7, 0.2), # Gold + Color(0.3, 0.9, 0.3), # Bright green + Color(0.2, 0.2, 0.9), # Bright blue + Color(0.9, 0.4, 0.6), # Hot pink + Color(0.5, 0.2, 0.8), # Deep purple + Color(0.9, 0.6, 0.1) # Amber ] var hair_color = hair_colors[appearance_rng.randi() % hair_colors.size()] _set_hair_color(hair_color) - # Set facial hair color to match hair color or slightly different + # Set facial hair color - usually matches hair, but can be different (30% chance) var facial_hair_color = hair_color - if appearance_rng.randf() < 0.3: # 30% chance for slightly different color - facial_hair_color = hair_colors[appearance_rng.randi() % hair_colors.size()] - _set_facial_hair_color(facial_hair_color) + if facial_hair_type > 0: # Only set color if they have facial hair + if appearance_rng.randf() < 0.3: # 30% chance for different color + facial_hair_color = hair_colors[appearance_rng.randi() % hair_colors.size()] + _set_facial_hair_color(facial_hair_color) + else: + # No facial hair, but still set a default color (won't be visible) + _set_facial_hair_color(hair_color) func _set_skin(i_value: int): if i_value < 0 or i_value > 6: @@ -348,6 +369,10 @@ func _set_hair(i_type: int): sprite_hair.texture = hair_texture sprite_hair.hframes = 35 sprite_hair.vframes = 8 + # Apply hair color after loading texture (in case it was set before) + # Note: This will be set in _randomize_appearance, but ensure it's applied here too + if sprite_hair.modulate != Color.WHITE: + _set_hair_color(sprite_hair.modulate) func _set_eye_lashes(i_eyelashes: int): if i_eyelashes < 0 or i_eyelashes > 8: @@ -460,11 +485,23 @@ func _set_ears(i_ears: int): sprite_addons.vframes = 8 func _set_facial_hair_color(i_color: Color): - if sprite_facial_hair: + if not sprite_facial_hair: + return + # Use shader tint parameter instead of modulate (same as players) + if sprite_facial_hair.material and sprite_facial_hair.material is ShaderMaterial: + sprite_facial_hair.material.set_shader_parameter("tint", Vector4(i_color.r, i_color.g, i_color.b, i_color.a)) + else: + # Fallback to modulate if no shader material sprite_facial_hair.modulate = i_color func _set_hair_color(i_color: Color): - if sprite_hair: + if not sprite_hair: + return + # Use shader tint parameter instead of modulate (same as players) + if sprite_hair.material and sprite_hair.material is ShaderMaterial: + sprite_hair.material.set_shader_parameter("tint", Vector4(i_color.r, i_color.g, i_color.b, i_color.a)) + else: + # Fallback to modulate if no shader material sprite_hair.modulate = i_color func _get_body_texture_for_type(type: HumanoidType) -> String: @@ -1264,6 +1301,25 @@ func _set_animation(anim_name: String): func _update_animation(delta): # Update animation frame timing (even when dead, to play death animation) + # CRITICAL: If dead, ensure DIE animation is set (don't let other animations override it) + if is_dead: + if current_animation != "DIE" and "DIE" in ANIMATIONS: + LogManager.log(str(name) + " (client) forcing DIE animation in _update_animation - was: " + str(current_animation), LogManager.CATEGORY_ENEMY) + current_animation = "DIE" + current_frame = 0 + time_since_last_frame = 0.0 + + # CRITICAL: Ensure we have a valid animation set + if not current_animation in ANIMATIONS: + LogManager.log(str(name) + " WARNING: current_animation '" + str(current_animation) + "' not in ANIMATIONS!", LogManager.CATEGORY_ENEMY) + # Fallback to IDLE if animation is invalid (or DIE if dead) + if is_dead and "DIE" in ANIMATIONS: + current_animation = "DIE" + elif "IDLE" in ANIMATIONS: + current_animation = "IDLE" + else: + return # Can't update animation without valid animation + time_since_last_frame += delta if time_since_last_frame >= ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0: var was_attacking = (current_animation == "SWORD" and current_frame == len(ANIMATIONS[current_animation]["frames"]) - 1) @@ -1404,13 +1460,37 @@ func _flash_damage(): tween.tween_property(sprite_layer, "modulate", Color.RED, 0.1) tween.tween_property(sprite_layer, "modulate", Color.WHITE, 0.1) -func _on_take_damage(): - # Override to play damage animation (same as player) +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(): # Override to play death animation (same as player) + # Ensure animation is set and animation state is properly initialized + LogManager.log(str(name) + " _play_death_animation() called, is_authority: " + str(is_multiplayer_authority()) + ", current_animation: " + str(current_animation), LogManager.CATEGORY_ENEMY) _set_animation("DIE") + # Force animation frame reset to ensure animation starts from beginning + current_frame = 0 + time_since_last_frame = 0.0 + LogManager.log(str(name) + " _play_death_animation() set DIE animation, current_frame: " + str(current_frame) + ", current_animation: " + str(current_animation), LogManager.CATEGORY_ENEMY) + + # CRITICAL: Force immediate visual update on clients to ensure animation is visible + # Call _update_animation with 0 delta to apply current frame immediately + if not is_multiplayer_authority(): + _update_animation(0.0) + LogManager.log(str(name) + " (client) forced immediate animation update after setting DIE", LogManager.CATEGORY_ENEMY) # Play death sound effect if sfx_die: diff --git a/src/scripts/enemy_slime.gd b/src/scripts/enemy_slime.gd index a7045b6..088473f 100644 --- a/src/scripts/enemy_slime.gd +++ b/src/scripts/enemy_slime.gd @@ -243,7 +243,7 @@ func _update_animation(delta): sprite.frame = frame_index anim_frame = frame_index # Keep anim_frame updated for compatibility -func _on_take_damage(): +func _on_take_damage(_attacker_position: Vector2 = Vector2.ZERO): # Play damage animation state = SlimeState.DAMAGED state_timer = 0.3 @@ -268,6 +268,15 @@ func _update_client_visuals(): # Update visuals on clients based on synced state super._update_client_visuals() + # CRITICAL: If dead, always show DIE animation (don't let state override it) + # This prevents DAMAGED state from showing DAMAGE animation after death sync + if is_dead: + _set_animation("DIE") + _update_animation(0.0) + if sprite: + sprite.frame = anim_frame + return + # Map synced state to animation (similar to how bat/rat use state directly) match state: SlimeState.IDLE: diff --git a/src/scripts/floor_switch.gd b/src/scripts/floor_switch.gd index 14763c6..1d1013c 100644 --- a/src/scripts/floor_switch.gd +++ b/src/scripts/floor_switch.gd @@ -94,30 +94,30 @@ func _on_body_exited(body): return if body in objects_on_switch: - # For pillar switches, verify the object is still valid (not being held now) - if switch_type == "pillar": - var object_type = body.get("object_type") if "object_type" in body else "" - var _is_being_held = body.get("is_being_held") if "is_being_held" in body else false - - # Only remove if it was a pillar (and might now be held) - if object_type == "Pillar": + # For pillar switches, verify the object is still valid (not being held now) + if switch_type == "pillar": + var object_type = body.get("object_type") if "object_type" in body else "" + var _is_being_held = body.get("is_being_held") if "is_being_held" in body else false + + # Only remove if it was a pillar (and might now be held) + if object_type == "Pillar": + var weight = _get_object_weight(body) + if weight > 0: + if is_inside_tree() and has_node("ReleaseSwitch") and get_node("ReleaseSwitch").is_inside_tree(): + get_node("ReleaseSwitch").play() + objects_on_switch.erase(body) + current_weight -= weight + print("FloorSwitch: Pillar left switch, weight: ", weight, ", total: ", current_weight) + _check_activation() + else: + # Walk switch: Remove any object var weight = _get_object_weight(body) if weight > 0: - if is_inside_tree() and $ReleaseSwitch: - $ReleaseSwitch.play() + if is_inside_tree() and has_node("ReleaseSwitch") and get_node("ReleaseSwitch").is_inside_tree(): + get_node("ReleaseSwitch").play() objects_on_switch.erase(body) current_weight -= weight - print("FloorSwitch: Pillar left switch, weight: ", weight, ", total: ", current_weight) _check_activation() - else: - # Walk switch: Remove any object - var weight = _get_object_weight(body) - if weight > 0: - if is_inside_tree() and $ReleaseSwitch: - $ReleaseSwitch.play() - objects_on_switch.erase(body) - current_weight -= weight - _check_activation() func _get_object_weight(body: Node) -> float: # Get weight of an object diff --git a/src/scripts/fog_of_war.gd b/src/scripts/fog_of_war.gd new file mode 100644 index 0000000..a2325b9 --- /dev/null +++ b/src/scripts/fog_of_war.gd @@ -0,0 +1,46 @@ +extends Node2D + +var map_size: Vector2i = Vector2i.ZERO +var tile_size: int = 16 +var explored_map: PackedInt32Array = PackedInt32Array() +var visible_map: PackedInt32Array = PackedInt32Array() +var fog_color_unseen: Color = Color(0, 0, 0, 1.0) +var fog_color_seen: Color = Color(0, 0, 0, 0.85) +var debug_lines: Array = [] +var debug_enabled: bool = false + +func setup(new_map_size: Vector2i, new_tile_size: int = 16) -> void: + map_size = new_map_size + tile_size = new_tile_size + +func set_maps(new_explored_map: PackedInt32Array, new_visible_map: PackedInt32Array) -> void: + explored_map = new_explored_map + visible_map = new_visible_map + queue_redraw() + +func set_debug_lines(lines: Array, enabled: bool) -> void: + debug_lines = lines + debug_enabled = enabled + queue_redraw() + +func _draw() -> void: + if map_size == Vector2i.ZERO or explored_map.is_empty() or visible_map.is_empty(): + return + + for x in range(map_size.x): + for y in range(map_size.y): + var idx = x + y * map_size.x + if idx >= explored_map.size() or idx >= visible_map.size(): + continue + var pos = Vector2(x * tile_size, y * tile_size) + if visible_map[idx] == 1: + continue + if explored_map[idx] == 0: + draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_unseen, true) + else: + draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_seen, true) + + if debug_enabled: + for line in debug_lines: + if line is Array and line.size() == 2: + draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0) \ No newline at end of file diff --git a/src/scripts/fog_of_war.gd.uid b/src/scripts/fog_of_war.gd.uid new file mode 100644 index 0000000..ee0a251 --- /dev/null +++ b/src/scripts/fog_of_war.gd.uid @@ -0,0 +1 @@ +uid://dvexhx0su0ung diff --git a/src/scripts/game_ui.gd b/src/scripts/game_ui.gd index 981cf6e..ca6a999 100644 --- a/src/scripts/game_ui.gd +++ b/src/scripts/game_ui.gd @@ -154,6 +154,7 @@ func _check_command_line_args(): LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ")...", LogManager.CATEGORY_UI) network_manager.set_local_player_count(local_count) is_auto_joining = true + is_joining_attempt = true # Mark as joining attempt so connection failure handler works # Create timer for retrying room fetches room_fetch_timer = Timer.new() room_fetch_timer.name = "RoomFetchTimer" @@ -188,8 +189,11 @@ func _on_rooms_fetched_display(rooms: Array): """Display available rooms when fetched (non-auto-join mode)""" # Only handle if not in auto-join mode (auto-join has its own handler) if is_auto_joining: + LogManager.log("GameUI: Ignoring rooms_fetched_display - still in auto-join mode", LogManager.CATEGORY_UI) return # Let auto-join handler take care of it + LogManager.log("GameUI: Received rooms for display: " + str(rooms.size()) + " rooms", LogManager.CATEGORY_UI) + # Hide loading indicator - request completed _hide_loading_indicator() @@ -237,12 +241,11 @@ func _on_rooms_fetched_auto_join(rooms: Array): # Stop retrying - we found rooms! if room_fetch_timer: room_fetch_timer.stop() - is_auto_joining = false - # Hide room fetch status UI - _hide_room_fetch_status() + # DON'T set is_auto_joining = false yet - wait until connection succeeds or fails + # DON'T hide room fetch status UI yet - keep it visible in case join fails - # Disconnect from signal since we're done + # Disconnect from auto-join handler (we'll connect to display handler if join fails) if network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): network_manager.rooms_fetched.disconnect(_on_rooms_fetched_auto_join) @@ -258,6 +261,9 @@ func _on_rooms_fetched_auto_join(rooms: Array): if room_fetch_timer: room_fetch_timer.start() is_auto_joining = true + # Reconnect to auto-join handler + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) # Keep showing status UI return @@ -269,7 +275,20 @@ func _on_rooms_fetched_auto_join(rooms: Array): network_manager.set_local_player_count(local_count) if network_manager.join_game(room_code): # Connection callback will handle starting the game + # Note: We'll hide the UI and set is_auto_joining = false in _on_connection_succeeded pass + else: + # Join failed immediately - switch to display mode + LogManager.log("Auto-join failed immediately, switching to room browser mode", LogManager.CATEGORY_UI) + is_auto_joining = false + # Connect to display handler + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) + # Keep room fetch status UI visible (with refresh button) + _show_room_fetch_status() + # Fetch and display available rooms + _show_loading_indicator() + _start_room_fetch() func _retry_room_fetch(): """Retry fetching available rooms""" @@ -375,6 +394,25 @@ func _create_refresh_button(): func _on_refresh_button_pressed(): """Handle refresh button click""" + LogManager.log("GameUI: Refresh button pressed", LogManager.CATEGORY_UI) + + # CRITICAL: Ensure we're not in auto-join mode when refreshing manually + # This prevents _on_rooms_fetched_display from ignoring the signal + if is_auto_joining: + LogManager.log("GameUI: Switching from auto-join to display mode for refresh", LogManager.CATEGORY_UI) + is_auto_joining = false + # Stop auto-join timer if it's running + if room_fetch_timer: + room_fetch_timer.stop() + # Disconnect auto-join handler + if network_manager and network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.disconnect(_on_rooms_fetched_auto_join) + + # Ensure display handler is connected (in case it was disconnected) + if network_manager and not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + LogManager.log("GameUI: Reconnecting rooms_fetched signal to display handler", LogManager.CATEGORY_UI) + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) + # Disable button and start cooldown if refresh_button: refresh_button.disabled = true @@ -394,10 +432,17 @@ func _on_refresh_cooldown_finished(): func _update_last_fetch_time(): """Update the last fetch time label with current datetime""" + # Try to find the label if it's null (might not be ready yet) + if not last_fetch_label: + last_fetch_label = get_node_or_null("Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/LastFetchLabel") + if last_fetch_label: var now = Time.get_datetime_dict_from_system() var time_str = "%02d:%02d:%02d" % [now.hour, now.minute, now.second] last_fetch_label.text = "Last fetched: " + time_str + LogManager.log("GameUI: Updated last fetch time to: " + time_str, LogManager.CATEGORY_UI) + else: + LogManager.log_error("GameUI: Cannot update last fetch time - last_fetch_label is null!") func _create_room_list_container(): """Create the container for displaying available rooms""" @@ -519,11 +564,23 @@ func _on_network_mode_changed(index: int): var mode_names = ["ENet", "WebRTC", "WebSocket"] LogManager.log("GameUI: Network mode changed to: " + mode_names[actual_mode], LogManager.CATEGORY_UI) - # If WebRTC is selected, fetch available rooms (unless we're auto-joining or hosting) - if actual_mode == 1 and not is_auto_joining and not is_hosting: # WebRTC mode - _start_room_fetch() - elif actual_mode != 1: # Not WebRTC mode + # Handle room fetching based on mode + if actual_mode == 1: # WebRTC mode + # Only fetch if not auto-joining and not hosting + if not is_auto_joining and not is_hosting and not network_manager.is_hosting: + LogManager.log("GameUI: Switched to WebRTC mode, fetching rooms", LogManager.CATEGORY_UI) + # Ensure display handler is connected + if network_manager and not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) + # Show room fetch status UI (with refresh button) + _show_room_fetch_status() + # Fetch rooms + _start_room_fetch() + else: + LogManager.log("GameUI: Switched to WebRTC mode but skipping room fetch (auto_joining: " + str(is_auto_joining) + ", hosting: " + str(is_hosting) + ")", LogManager.CATEGORY_UI) + else: # Not WebRTC mode (ENet or WebSocket) # Hide room fetch status if switching away from WebRTC + LogManager.log("GameUI: Switched away from WebRTC mode, hiding room fetch UI", LogManager.CATEGORY_UI) _hide_room_fetch_status() func _on_host_pressed(): @@ -572,6 +629,16 @@ func _on_join_pressed(): func _on_connection_succeeded(): LogManager.log("GameUI: Connection succeeded signal received, starting game", LogManager.CATEGORY_UI) is_joining_attempt = false + + # If we were in auto-join mode, now we can safely exit it + if is_auto_joining: + is_auto_joining = false + # Hide room fetch status UI since we're connecting + _hide_room_fetch_status() + # Disconnect from auto-join handler if still connected + if network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.disconnect(_on_rooms_fetched_auto_join) + # Check if node is still valid before starting game if not is_inside_tree(): LogManager.log_error("GameUI: Cannot start game - node not in tree", LogManager.CATEGORY_UI) @@ -593,6 +660,28 @@ func _on_connection_failed(): elif mode == 2: mode_name = "WebSocket" + # If we were in auto-join mode, switch to display mode and show rooms + if is_auto_joining: + LogManager.log("Connection failed during auto-join, switching to room browser mode", LogManager.CATEGORY_UI) + is_auto_joining = false + # Stop auto-join timer + if room_fetch_timer: + room_fetch_timer.stop() + # Disconnect from auto-join handler + if network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.disconnect(_on_rooms_fetched_auto_join) + # Connect to display handler instead + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) + # Show room fetch status UI (refresh button and room list) + _show_room_fetch_status() + # Fetch and display available rooms + _show_loading_indicator() + _start_room_fetch() + # Show error message + _show_connection_error("Failed to auto-join room. Showing available rooms below.") + return + if is_joining_attempt: var code_hint = (" (" + last_join_address + ")") if not last_join_address.is_empty() else "" _show_connection_error("Failed to join room" + code_hint + ". Did you enter the correct code?") @@ -603,6 +692,9 @@ func _on_connection_failed(): _show_room_fetch_status() _show_loading_indicator() _start_room_fetch() + # Also connect to display handler if not already connected + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) else: _show_connection_error("Connection failed (" + mode_name + "). Please try again.") diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 29b4c07..f45b696 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -10,6 +10,26 @@ var local_players = [] const BASE_CAMERA_ZOOM: float = 4.0 const REFERENCE_ASPECT: float = 16.0 / 9.0 +# Fog of war +const FOG_TILE_SIZE: int = 16 +const FOG_VIEW_RANGE_TILES: float = 10.0 +const FOG_BACK_RANGE_TILES: float = 3.0 +const FOG_RAY_STEP: float = 0.5 +const FOG_RAY_ANGLE_STEP: int = 10 +const FOG_UPDATE_INTERVAL: float = 0.1 +const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag +const FOG_DEBUG_DRAW: bool = false +var fog_update_timer: float = 0.0 +var fog_node: Node2D = null +var cached_corridor_mask: PackedInt32Array = PackedInt32Array() +var cached_corridor_rooms: Array = [] +var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) +var cached_corridor_allowed_room_ids: Dictionary = {} +var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen) +var combined_seen: PackedInt32Array = PackedInt32Array() +var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored +var fog_debug_lines: Array = [] + # Dungeon generation var dungeon_data: Dictionary = {} var dungeon_tilemap_layer: TileMapLayer = null @@ -17,6 +37,14 @@ var dungeon_tilemap_layer_above: TileMapLayer = null var current_level: int = 1 var dungeon_seed: int = 0 +# Chunked dungeon sync state (client only) +var dungeon_sync_metadata: Dictionary = {} # map_size, seed, level, host_room +var dungeon_sync_chunks: Dictionary = {} # chunk_idx -> {tile_grid_chunk, grid_chunk} +var dungeon_sync_rooms: Dictionary = {} # start_room, rooms +var dungeon_sync_total_chunks: int = 0 +var dungeon_sync_received_chunks: int = 0 +var dungeon_sync_complete: bool = false + # Level stats tracking var level_enemies_defeated: int = 0 var level_times_downed: int = 0 @@ -26,6 +54,16 @@ var level_coins_collected: int = 0 # Client ready tracking (server only) var clients_ready: Dictionary = {} # peer_id -> bool +# Track dungeon syncs in progress (server only) - prevent multiple simultaneous syncs +var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool + +# Track chunk acknowledgments (server only) - for flow control +var dungeon_chunk_acks: Dictionary = {} # peer_id -> {chunk_idx -> bool, next_chunk_to_send -> int, chunks_data -> Array} + +# Pre-packed dungeon blob (server only) - packed once, sent many times +var dungeon_blob_chunks: Array = [] # Array of PackedByteArray chunks (<16KB each) +var dungeon_blob_metadata: Dictionary = {} # seed, level, map_size, host_room + # Level complete tracking var level_complete_triggered: bool = false # Prevent multiple level complete triggers @@ -89,6 +127,12 @@ func _ready(): # Clients spawn players immediately (they'll be moved when dungeon syncs) if multiplayer.has_multiplayer_peer(): call_deferred("_spawn_all_players") + # Initialize camera after a short delay to ensure players are spawned + # This prevents black screen before dungeon sync arrives + get_tree().create_timer(0.1).timeout.connect(func(): + if is_inside_tree(): + _update_camera() + ) # Notify server that GameWorld is ready (client only) if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): @@ -97,15 +141,60 @@ func _ready(): func _send_gameworld_ready(): # Client notifies server that GameWorld is ready to receive RPCs if not is_inside_tree(): + LogManager.log("GameWorld: Cannot send gameworld_ready - not in tree", LogManager.CATEGORY_NETWORK) return if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): - # Ensure host peer (1) is known before sending RPC to avoid errors on web - if 1 not in multiplayer.get_peers(): - # Retry shortly; host peer may not be registered yet - get_tree().create_timer(0.2).timeout.connect(func(): _send_gameworld_ready()) - return + var peers = multiplayer.get_peers() + + # On web, multiplayer.get_peers() might never include peer 1 even though Matchbox connection is ready + # Check Matchbox connection state instead if available + var matchbox_connected = false + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + var pc = matchbox.peer_connections.get(1) if "peer_connections" in matchbox else null + if pc: + var connection_state = pc.get_connection_state() + var signaling_state = pc.get_signaling_state() + # Matchbox connection is ready if signaling is STABLE (0) or connection is CONNECTING/CONNECTED + matchbox_connected = (signaling_state == 0 or connection_state >= 1) + LogManager.log("GameWorld: Matchbox connection state for peer 1: connection=" + str(connection_state) + ", signaling=" + str(signaling_state) + ", matchbox_connected=" + str(matchbox_connected), LogManager.CATEGORY_NETWORK) + + var is_peer_in_list = (1 in peers) + LogManager.log("GameWorld: Client sending gameworld_ready, peers: " + str(peers) + ", matchbox_connected: " + str(matchbox_connected), LogManager.CATEGORY_NETWORK) + + # If peer is in multiplayer list, send RPC immediately + # If not, but Matchbox connection is ready, try sending anyway (might work on web) + # Otherwise, retry + if not is_peer_in_list and not matchbox_connected: + # Retry - on web, this can take several seconds + var retry_count = get_meta("gameworld_ready_retry_count", 0) + if retry_count < 50: # Try up to 50 times (10 seconds total) + set_meta("gameworld_ready_retry_count", retry_count + 1) + LogManager.log("GameWorld: Host peer (1) not in peers and Matchbox not connected yet, retrying (" + str(retry_count + 1) + "/50)...", LogManager.CATEGORY_NETWORK) + get_tree().create_timer(0.2).timeout.connect(func(): _send_gameworld_ready()) + return + else: + # After max retries, log warning and continue retrying (but less frequently) + LogManager.log("GameWorld: Max retries reached for gameworld_ready, continuing with slower retries (every 1 second)...", LogManager.CATEGORY_NETWORK) + get_tree().create_timer(1.0).timeout.connect(func(): _send_gameworld_ready()) + return + + # Peer is in list OR Matchbox connection is ready - try to send RPC var peer_id = multiplayer.get_unique_id() - _notify_gameworld_ready.rpc_id(1, peer_id) + + # On web, if peer isn't in get_peers(), the RPC will always fail + # So we only try to send if peer is in list, otherwise rely on server's proactive sync + if is_peer_in_list: + LogManager.log("GameWorld: Sending _notify_gameworld_ready to server (peer 1) from client " + str(peer_id), LogManager.CATEGORY_NETWORK) + _notify_gameworld_ready.rpc_id(1, peer_id) + # Reset retry count on success + remove_meta("gameworld_ready_retry_count") + else: + # Peer not in list - RPC will fail on web, so don't try + # Server will proactively send dungeon sync after 3 seconds via fallback + LogManager.log("GameWorld: Peer not in multiplayer list (web issue) - skipping RPC, server will sync proactively", LogManager.CATEGORY_NETWORK) + # Still reset retry count since we're giving up on RPC + remove_meta("gameworld_ready_retry_count") @rpc("any_peer", "reliable") func _notify_gameworld_ready(peer_id: int): @@ -158,11 +247,46 @@ func _on_player_connected(peer_id: int, player_info: Dictionary): player_manager.spawn_players_for_peer(peer_id, player_info.local_player_count) # Wait for client GameWorld to be ready before sending RPCs + # On web, _notify_gameworld_ready RPC might fail, so we proactively send sync after a delay if client_gameworld_ready.get(peer_id, false): + LogManager.log("GameWorld: Client " + str(peer_id) + " already marked as ready, sending sync immediately", LogManager.CATEGORY_NETWORK) _send_initial_client_sync(peer_id, player_info.local_player_count) else: client_gameworld_sync_pending[peer_id] = player_info.local_player_count _schedule_gameworld_ready_check(peer_id, player_info.local_player_count) + # On web, RPC might fail, so also schedule a proactive sync after delay + # This ensures clients get dungeon sync even if _notify_gameworld_ready fails + # For WebAssembly, we use shorter delays and multiple attempts + LogManager.log("GameWorld: Scheduling proactive sync for peer " + str(peer_id) + " (web workaround - multiple attempts)", LogManager.CATEGORY_NETWORK) + + # Try sync after 1 second (quick attempt) + get_tree().create_timer(1.0).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + if not client_gameworld_ready.get(peer_id, false): + LogManager.log("GameWorld: Proactive sync attempt 1 for peer " + str(peer_id), LogManager.CATEGORY_NETWORK) + client_gameworld_ready[peer_id] = true + _send_initial_client_sync_with_retry(peer_id, player_info.local_player_count) + ) + + # Try sync after 3 seconds (backup attempt) + get_tree().create_timer(3.0).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + if not client_gameworld_ready.get(peer_id, false): + LogManager.log("GameWorld: Proactive sync attempt 2 for peer " + str(peer_id) + " (RPC notification may have failed on web)", LogManager.CATEGORY_NETWORK) + client_gameworld_ready[peer_id] = true + _send_initial_client_sync_with_retry(peer_id, player_info.local_player_count) + else: + LogManager.log("GameWorld: Proactive sync skipped for peer " + str(peer_id) + " (already synced)", LogManager.CATEGORY_NETWORK) + ) + + # Try sync after 5 seconds (last resort for web) + get_tree().create_timer(5.0).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + if not client_gameworld_ready.get(peer_id, false): + LogManager.log("GameWorld: Proactive sync attempt 3 (last resort) for peer " + str(peer_id), LogManager.CATEGORY_NETWORK) + client_gameworld_ready[peer_id] = true + _send_initial_client_sync_with_retry(peer_id, player_info.local_player_count) + ) else: # Clients spawn directly when they receive this signal LogManager.log("GameWorld: Client spawning players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) @@ -193,11 +317,48 @@ func _send_initial_client_sync(peer_id: int, local_count: int): if not client_gameworld_ready.get(peer_id, false): return + # Use retry mechanism for web compatibility + _send_initial_client_sync_with_retry(peer_id, local_count) + +func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_count: int = 0): + # Send initial dungeon and world state to a client with retry logic for web + if not multiplayer.is_server(): + return + + # Check if peer is recognized in multiplayer system + var peers = multiplayer.get_peers() + var is_peer_recognized = (peer_id in peers) + + # On web, peer might not be recognized even though Matchbox connection is ready + # Check Matchbox connection state as fallback + var matchbox_connected = false + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + var pc = matchbox.peer_connections.get(peer_id) if "peer_connections" in matchbox else null + if pc: + var connection_state = pc.get_connection_state() + var signaling_state = pc.get_signaling_state() + matchbox_connected = (signaling_state == 0 or connection_state >= 1) + + # If peer not recognized and Matchbox not connected, retry + if not is_peer_recognized and not matchbox_connected: + if retry_count < 15: # Try up to 15 times (3 seconds total) - reduced from 30 + LogManager.log("GameWorld: Peer " + str(peer_id) + " not recognized yet, retrying sync (" + str(retry_count + 1) + "/15)...", LogManager.CATEGORY_NETWORK) + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _send_initial_client_sync_with_retry(peer_id, local_count, retry_count + 1) + ) + return + else: + # After max retries, try anyway if Matchbox connection exists + LogManager.log("GameWorld: Max retries reached for peer " + str(peer_id) + ", attempting sync anyway (Matchbox connection may be ready)", LogManager.CATEGORY_NETWORK) + # Sync existing dungeon to the new client (if dungeon has been generated) if not dungeon_data.is_empty(): - LogManager.log("GameWorld: Syncing existing dungeon to client " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) + LogManager.log("GameWorld: Syncing existing dungeon to client " + str(peer_id) + " (is_peer_recognized: " + str(is_peer_recognized) + ", matchbox_connected: " + str(matchbox_connected) + ")", LogManager.CATEGORY_GAMEPLAY) var host_room = _get_host_room() # Defer the RPC call to ensure the peer is registered in the multiplayer system + # On web, this might still fail, but we'll retry if needed call_deferred("_sync_dungeon_to_client", peer_id, dungeon_data, dungeon_seed, current_level, host_room) # Sync spawn to all clients - wait a bit for data channel to be ready @@ -207,6 +368,10 @@ func _send_initial_client_sync(peer_id: int, local_count: int): _rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count]) ) + # Sync broken interactable objects to the new client + # Wait a bit after dungeon sync to ensure objects are spawned first + call_deferred("_sync_broken_objects_to_client", peer_id) + # Sync existing enemies (from spawners) to the new client # Wait a bit after dungeon sync to ensure spawners are spawned first call_deferred("_sync_existing_enemies_to_client", peer_id) @@ -215,10 +380,6 @@ func _send_initial_client_sync(peer_id: int, local_count: int): # Wait a bit after dungeon sync to ensure objects are spawned first call_deferred("_sync_existing_chest_states_to_client", peer_id) - # Sync broken interactable objects to the new client - # Wait a bit after dungeon sync to ensure objects are spawned first - call_deferred("_sync_broken_objects_to_client", peer_id) - # Note: Dungeon-spawned enemies are already synced via _sync_dungeon RPC # which includes dungeon_data.enemies and calls _spawn_enemies() on the client. # So we don't need to sync them again with individual RPCs. @@ -226,37 +387,42 @@ func _send_initial_client_sync(peer_id: int, local_count: int): # Note: Interactable objects are also synced via _sync_dungeon RPC # which includes dungeon_data.interactable_objects and calls _spawn_interactable_objects() on the client. # However, chest open states and broken objects need to be synced separately since they change during gameplay. - + # Sync existing torches to the new client - # Wait longer to ensure dungeon is fully spawned - get_tree().create_timer(0.3).timeout.connect(func(): + # Wait until AFTER dungeon chunks are sent (8 chunks * 0.15s = 1.2s, plus rooms/entities = ~1.6s) + # Add extra buffer to ensure chunks are complete + get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): _sync_existing_torches_to_client(peer_id) ) - # Sync door states to the new client (wait longer to ensure doors are spawned) - get_tree().create_timer(0.3).timeout.connect(func(): + # Sync door states to the new client (wait until after dungeon chunks) + get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): _sync_existing_door_states_to_client(peer_id) ) # Sync defeated enemies to the new client (so they don't spawn) - call_deferred("_sync_defeated_enemies_to_client", peer_id) + # Wait until after dungeon chunks + get_tree().create_timer(2.0).timeout.connect(func(): + if is_inside_tree(): + _sync_defeated_enemies_to_client(peer_id) + ) # Sync existing loot on the ground to the new client - get_tree().create_timer(0.3).timeout.connect(func(): + get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): _sync_existing_loot_to_client(peer_id) ) # Sync activated floor switches to the new client - get_tree().create_timer(0.3).timeout.connect(func(): + get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): _sync_activated_switches_to_client(peer_id) ) # Sync interactable object positions (boxes that were moved/thrown) - get_tree().create_timer(0.4).timeout.connect(func(): + get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): _sync_interactable_object_positions_to_client(peer_id) ) @@ -265,8 +431,86 @@ func _rpc_to_ready_peers(method: String, args: Array = []): # Send RPC to all clients whose GameWorld is ready if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): return - for peer_id in multiplayer.get_peers(): + + # Get peers list once to avoid multiple calls + var peers = multiplayer.get_peers() + + for peer_id in peers: if client_gameworld_ready.get(peer_id, false): + # Final check: verify peer is still in get_peers() right before sending + # This minimizes the race condition window + var current_peers = multiplayer.get_peers() + if peer_id not in current_peers: + client_gameworld_ready.erase(peer_id) + continue + + # For WebRTC, verify data channels are actually open right before sending + # This check happens immediately before the RPC to minimize race condition window + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if not webrtc_peer.has_peer(peer_id): + client_gameworld_ready.erase(peer_id) + continue + + var peer_info = webrtc_peer.get_peer(peer_id) + if not peer_info: + client_gameworld_ready.erase(peer_id) + continue + + # Check if data channels are connected (this is the critical check) + var is_net_connected = peer_info.get("connected", false) + if not is_net_connected: + client_gameworld_ready.erase(peer_id) + continue + + # Also check connection state to be extra safe + var connection_obj = peer_info.get("connection") + if connection_obj != null and typeof(connection_obj) == TYPE_INT: + var connection_val = int(connection_obj) + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + client_gameworld_ready.erase(peer_id) + continue + + # Also verify channels array to ensure channels are actually open + # The "connected" flag might not update immediately when channels close + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + var all_channels_open = true + for channel in channels: + if channel != null: + # Check if channel has ready_state method + if channel.has_method("get_ready_state"): + var ready_state = channel.get_ready_state() + # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if ready_state != 1: # Not OPEN + all_channels_open = false + break + elif "ready_state" in channel: + var ready_state = channel.get("ready_state") + if ready_state != 1: # Not OPEN + all_channels_open = false + break + if not all_channels_open: + client_gameworld_ready.erase(peer_id) + continue + + # Also check matchbox_client connection state for additional verification + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + if "peer_connections" in matchbox: + var pc = matchbox.peer_connections.get(peer_id) + if pc and pc.has_method("get_connection_state"): + var matchbox_conn_state = pc.get_connection_state() + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + client_gameworld_ready.erase(peer_id) + continue + + # All checks passed, send RPC + # Note: Even with all checks, there's still a tiny race condition window, + # but this minimizes it significantly. If channel closes between check and send, + # Godot will log the error but it won't crash. callv("rpc_id", [peer_id, method] + args) func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): @@ -275,10 +519,123 @@ func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): return if not node: return - for peer_id in multiplayer.get_peers(): + + # Get peers list once to avoid multiple calls + var peers = multiplayer.get_peers() + + for peer_id in peers: if client_gameworld_ready.get(peer_id, false): + # Final check: verify peer is still in get_peers() right before sending + # This minimizes the race condition window + var current_peers = multiplayer.get_peers() + if peer_id not in current_peers: + client_gameworld_ready.erase(peer_id) + continue + + # For WebRTC, verify data channels are actually open right before sending + # This check happens immediately before the RPC to minimize race condition window + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if not webrtc_peer.has_peer(peer_id): + client_gameworld_ready.erase(peer_id) + continue + + var peer_info = webrtc_peer.get_peer(peer_id) + if not peer_info: + client_gameworld_ready.erase(peer_id) + continue + + # Check if data channels are connected (this is the critical check) + var is_net_connected = peer_info.get("connected", false) + if not is_net_connected: + client_gameworld_ready.erase(peer_id) + continue + + # Also check connection state to be extra safe + var connection_obj = peer_info.get("connection") + if connection_obj != null and typeof(connection_obj) == TYPE_INT: + var connection_val = int(connection_obj) + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + client_gameworld_ready.erase(peer_id) + continue + + # Also verify channels array to ensure channels are actually open + # The "connected" flag might not update immediately when channels close + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + var all_channels_open = true + for channel in channels: + if channel != null: + # Check if channel has ready_state method + if channel.has_method("get_ready_state"): + var ready_state = channel.get_ready_state() + # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if ready_state != 1: # Not OPEN + all_channels_open = false + break + elif "ready_state" in channel: + var ready_state = channel.get("ready_state") + if ready_state != 1: # Not OPEN + all_channels_open = false + break + if not all_channels_open: + client_gameworld_ready.erase(peer_id) + continue + + # Also check matchbox_client connection state for additional verification + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + if "peer_connections" in matchbox: + var pc = matchbox.peer_connections.get(peer_id) + if pc and pc.has_method("get_connection_state"): + var matchbox_conn_state = pc.get_connection_state() + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + client_gameworld_ready.erase(peer_id) + continue + + # All checks passed, send RPC + # Note: Even with all checks, there's still a tiny race condition window, + # but this minimizes it significantly. If channel closes between check and send, + # Godot will log the error but it won't crash. node.callv("rpc_id", [peer_id, method] + args) +func _is_peer_connected(peer_id: int) -> bool: + """Check if a peer is still connected and has open data channels""" + if not multiplayer.has_multiplayer_peer(): + return false + + # Check if peer is in get_peers() + var peers = multiplayer.get_peers() + if peer_id not in peers: + return false + + # For WebRTC, check if data channels are actually open + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if webrtc_peer.has_peer(peer_id): + var peer_info = webrtc_peer.get_peer(peer_id) + if peer_info: + # Check if data channels are connected + var is_net_connected = peer_info.get("connected", false) + if not is_net_connected: + return false + # Also check connection state + var connection_obj = peer_info.get("connection") + if connection_obj != null: + var connection_val: int = -1 + if typeof(connection_obj) == TYPE_INT: + connection_val = int(connection_obj) + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + return false + else: + # Peer not found in WebRTC peer list + return false + + return true + func _sync_existing_enemies_to_client(client_peer_id: int): # Find all enemy spawners and sync their spawned enemies to the new client # Spawners are children of the Entities node, not GameWorld directly @@ -318,6 +675,16 @@ func _sync_existing_enemies_to_client(client_peer_id: int): func _on_player_disconnected(peer_id: int, player_info: Dictionary): print("GameWorld: Player disconnected - ", peer_id) + # Clean up ready status for disconnected peer + if client_gameworld_ready.has(peer_id): + client_gameworld_ready.erase(peer_id) + if clients_ready.has(peer_id): + clients_ready.erase(peer_id) + + # Clear dungeon sync in progress flag + if dungeon_sync_in_progress.has(peer_id): + dungeon_sync_in_progress.erase(peer_id) + # Send disconnect message to chat (only on server to avoid duplicates) if multiplayer.is_server(): _send_player_disconnect_message(peer_id, player_info) @@ -453,11 +820,13 @@ func _sync_loot_spawn(spawn_position: Vector2, loot_type: int, initial_velocity: if loot_type == 4: # LootType.KEY loot.name = "KeyLoot_%d_%d" % [int(spawn_position.x), int(spawn_position.y)] # Set initial velocity before _ready() processes + # Use synced velocity to ensure bounce matches server loot.velocity = initial_velocity loot.velocity_z = initial_velocity_z loot.velocity_set_by_spawner = true - loot.is_airborne = true - print("Client spawned loot: ", loot_type, " at ", spawn_position, " authority: ", loot.get_multiplayer_authority()) + # Only set airborne if there's actual velocity (loot that's still bouncing) + loot.is_airborne = (initial_velocity_z > 0.0 or initial_velocity.length() > 0.0) + print("Client spawned loot: ", loot_type, " at ", spawn_position, " velocity: ", initial_velocity, " velocity_z: ", initial_velocity_z, " airborne: ", loot.is_airborne) @rpc("authority", "reliable") func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initial_velocity: Vector2, initial_velocity_z: float, loot_id: int = -1): @@ -484,11 +853,67 @@ func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initi loot.loot_type = loot.LootType.ITEM loot.item = item # Set the item instance # Set initial velocity before _ready() processes + # Use synced velocity to ensure bounce matches server loot.velocity = initial_velocity loot.velocity_z = initial_velocity_z loot.velocity_set_by_spawner = true - loot.is_airborne = true - print("Client spawned item loot: ", item.item_name, " at ", spawn_position, " authority: ", loot.get_multiplayer_authority()) + # Only set airborne if there's actual velocity (loot that's still bouncing) + loot.is_airborne = (initial_velocity_z > 0.0 or initial_velocity.length() > 0.0) + print("Client spawned item loot: ", item.item_name, " at ", spawn_position, " velocity: ", initial_velocity, " velocity_z: ", initial_velocity_z, " airborne: ", loot.is_airborne) + +@rpc("authority", "unreliable") +func _sync_loot_physics(loot_id: int, pos: Vector2, velocity: Vector2, position_z: float, velocity_z: float): + # Clients receive periodic physics sync from server for reconciliation + if multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + # Find loot by ID + var loot = null + for child in entities_node.get_children(): + if child.is_in_group("loot") and child.has_meta("loot_id"): + if child.get_meta("loot_id") == loot_id: + loot = child + break + + if loot and is_instance_valid(loot): + # Store server-authoritative state for reconciliation + loot.server_position = pos + loot.server_velocity = velocity + loot.server_position_z = position_z + loot.server_velocity_z = velocity_z + loot.last_sync_time = Time.get_ticks_msec() / 1000.0 + + # Reconcile: smooth correction if prediction error is significant + _reconcile_loot_state(loot) + +func _reconcile_loot_state(loot): + """Reconcile client prediction with server state - smooth correction""" + if not loot or not is_instance_valid(loot): + return + + # Calculate prediction error + var pos_error = loot.global_position.distance_to(loot.server_position) + var z_error = abs(loot.position_z - loot.server_position_z) + + # If error is significant, apply smooth correction + if pos_error > loot.prediction_error_threshold or z_error > loot.prediction_error_threshold * 0.5: + # Smooth correction using lerp (prevents jerky snaps) + loot.global_position = loot.global_position.lerp(loot.server_position, loot.correction_smoothing) + loot.velocity = loot.velocity.lerp(loot.server_velocity, loot.correction_smoothing) + loot.position_z = lerp(loot.position_z, loot.server_position_z, loot.correction_smoothing) + loot.velocity_z = lerp(loot.velocity_z, loot.server_velocity_z, loot.correction_smoothing) + # Update airborne state from server + loot.is_airborne = (loot.server_velocity_z > 0.0 or loot.server_velocity.length() > 0.0) + else: + # Error is small - still update velocity to match server for better prediction + # This helps keep prediction accurate over time + loot.velocity = loot.velocity.lerp(loot.server_velocity, loot.correction_smoothing * 0.5) + loot.velocity_z = lerp(loot.velocity_z, loot.server_velocity_z, loot.correction_smoothing * 0.5) + loot.is_airborne = (loot.server_velocity_z > 0.0 or loot.server_velocity.length() > 0.0) @rpc("authority", "reliable") func _sync_loot_floating_text(loot_type_value: int, text: String, color_value: Color, sprite_frame_value: int, player_peer_id: int): @@ -908,6 +1333,7 @@ func _sync_loot_remove(loot_id: int, loot_position: Vector2): func _process(_delta): # Update camera to follow local players _update_camera() + _update_fog_of_war(_delta) func _update_camera(): local_players = player_manager.get_local_players() @@ -945,8 +1371,446 @@ func _update_camera(): var spread_zoom = clamp(800.0 / (max_distance + 400.0), 0.5, 1.5) target_zoom *= spread_zoom + # Always update zoom (for both single and multi-player) camera.zoom = camera.zoom.lerp(Vector2.ONE * target_zoom, 0.05) +func _init_fog_of_war(): + if dungeon_data.is_empty() or not dungeon_data.has("map_size"): + return + + # Create or reset fog node + if fog_node and is_instance_valid(fog_node): + fog_node.queue_free() + fog_node = null + + var fog_script = load("res://scripts/fog_of_war.gd") + fog_node = Node2D.new() + fog_node.name = "FogOfWar" + fog_node.z_index = 100 + fog_node.set_script(fog_script) + add_child(fog_node) + if fog_node.has_method("setup"): + fog_node.setup(dungeon_data.map_size, FOG_TILE_SIZE) + + # Initialize seen arrays for current players + seen_by_player.clear() + var players = get_tree().get_nodes_in_group("player") + for player in players: + if player and player.name != "": + seen_by_player[player.name] = _create_seen_array(dungeon_data.map_size) + + combined_seen = _create_seen_array(dungeon_data.map_size) + explored_map = _create_seen_array(dungeon_data.map_size) + + # Clear corridor cache when initializing new fog + cached_corridor_mask.clear() + cached_corridor_rooms.clear() + cached_corridor_player_tile = Vector2i(-1, -1) + cached_corridor_allowed_room_ids.clear() + +func _update_fog_of_war(delta: float) -> void: + if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"): + return + + # Determine if we're in a corridor and use appropriate update interval + var in_corridor = false + if player_manager.get_local_players().size() > 0 and player_manager.get_local_players()[0]: + var p_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)) + in_corridor = _find_room_at_tile(p_tile).is_empty() + + var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL + fog_update_timer += delta + if fog_update_timer < update_interval: + return + fog_update_timer = 0.0 + + var map_size = dungeon_data.map_size + combined_seen = _create_seen_array(map_size) + if explored_map.is_empty(): + explored_map = _create_seen_array(map_size) + + var local_player_list = player_manager.get_local_players() + fog_debug_lines.clear() + for player in local_player_list: + if not player or not is_instance_valid(player): + continue + if not seen_by_player.has(player.name): + seen_by_player[player.name] = _create_seen_array(map_size) + var seen_map = seen_by_player[player.name] + _update_seen_for_player(player, seen_map) + _combine_seen_maps(combined_seen, seen_map) + _combine_seen_maps(explored_map, seen_map) + + # Mask visibility to current room only (hide other rooms even if previously seen) + var current_room = {} + if local_player_list.size() > 0 and local_player_list[0]: + var p_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) + current_room = _find_room_at_tile(p_tile) + if not current_room.is_empty(): + _mark_room_explored(current_room) + _mark_room_visible(current_room) + for y in range(map_size.y): + for x in range(map_size.x): + if not _is_tile_in_room_or_walls(Vector2i(x, y), current_room): + var idx = x + y * map_size.x + if idx >= 0 and idx < combined_seen.size(): + combined_seen[idx] = 0 + 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)) + + # 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 corridor_mask: PackedInt32Array + var corridor_rooms: Array + var allowed_room_ids: Dictionary + + 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 + + # Build a set of allowed room IDs for fast lookup + cached_corridor_allowed_room_ids = {} + for room in cached_corridor_rooms: + var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) + cached_corridor_allowed_room_ids[room_id] = true + + corridor_mask = cached_corridor_mask + corridor_rooms = cached_corridor_rooms + allowed_room_ids = cached_corridor_allowed_room_ids + else: + # Use cached data (much faster!) + corridor_mask = cached_corridor_mask + corridor_rooms = cached_corridor_rooms + allowed_room_ids = cached_corridor_allowed_room_ids + + # Check explored rooms and mark them visible + for room in corridor_rooms: + # If this room was previously explored, mark the entire room (including outer walls) as visible + var was_explored = false + for x in range(room.x - 2, room.x + room.w + 2): + for y in range(room.y - 2, room.y + room.h + 2): + if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: + continue + var idx = x + y * map_size.x + if idx >= 0 and idx < explored_map.size() and explored_map[idx] == 1: + was_explored = true + break + if was_explored: + break + if was_explored: + _mark_room_visible(room) + # Explicitly clear combined_seen for ANY tile not in corridor mask or allowed rooms + for y in range(map_size.y): + for x in range(map_size.x): + var idx = x + y * map_size.x + if idx < 0 or idx >= combined_seen.size(): + continue + var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1 + # Check if this tile is in a room, and if so, is it an allowed room? + var tile_room = _find_room_at_tile(Vector2i(x, y)) + var in_allowed_room = false + if not tile_room.is_empty(): + var room_id = str(tile_room.x) + "," + str(tile_room.y) + "," + str(tile_room.w) + "," + str(tile_room.h) + in_allowed_room = allowed_room_ids.has(room_id) + # Clear combined_seen for any tile not in corridor or allowed rooms + if not tile_in_corridor and not in_allowed_room: + combined_seen[idx] = 0 + + if fog_node.has_method("set_maps"): + fog_node.set_maps(explored_map, combined_seen) + if fog_node.has_method("set_debug_lines"): + fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW) + +func _create_seen_array(map_size: Vector2i) -> PackedInt32Array: + var size = map_size.x * map_size.y + var arr = PackedInt32Array() + arr.resize(size) + for i in range(size): + arr[i] = 0 + return arr + +func _combine_seen_maps(target: PackedInt32Array, source: PackedInt32Array) -> void: + var size = min(target.size(), source.size()) + for i in range(size): + if source[i] != 0: + target[i] = 1 + +func _update_seen_for_player(player: Node, seen_map: PackedInt32Array) -> void: + var map_size = dungeon_data.map_size + var p_tile = Vector2(player.global_position.x / FOG_TILE_SIZE, player.global_position.y / FOG_TILE_SIZE) + var current_room = _find_room_at_tile(Vector2i(int(p_tile.x), int(p_tile.y))) + var view_dir = _get_player_view_dir(player) + _mark_seen(seen_map, Vector2i(int(p_tile.x), int(p_tile.y))) + + # When in corridor, restrict raycasts to corridor + connected rooms only + var allowed_rooms_for_corridor = null + if current_room.is_empty(): + var player_tile = Vector2i(int(p_tile.x), int(p_tile.y)) + var corridor_mask = _build_corridor_mask(player_tile) + allowed_rooms_for_corridor = _get_rooms_connected_to_corridor(corridor_mask, player_tile) + + for angle in range(0, 360, FOG_RAY_ANGLE_STEP): + var rad = deg_to_rad(float(angle)) + var dir = Vector2(cos(rad), sin(rad)) + var angle_weight = _get_view_angle_weight(view_dir, dir) + var ray_range = lerp(FOG_BACK_RANGE_TILES, FOG_VIEW_RANGE_TILES, angle_weight) + var last_tile = Vector2i(-999, -999) + var steps = int(ray_range / FOG_RAY_STEP) + var after_block_tiles = 0 + var last_world = player.global_position + for s in range(steps + 1): + var dist = float(s) * FOG_RAY_STEP + var sample = p_tile + dir * dist + var tile = Vector2i(int(floor(sample.x)), int(floor(sample.y))) + if tile == last_tile: + continue + last_tile = tile + if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y: + break + # If player is inside a room, don't reveal tiles outside that room (including walls) + if not current_room.is_empty() and not _is_tile_in_room_or_walls(tile, current_room): + break + # If player is in corridor, only allow seeing tiles in corridor or connected rooms + if current_room.is_empty() and allowed_rooms_for_corridor != null: + var tile_room = _find_room_at_tile(tile) + # If tile is in a room, check if it's one of the allowed (connected) rooms + if not tile_room.is_empty(): + var in_allowed_room = false + for room in allowed_rooms_for_corridor: + if _is_tile_in_room_or_walls(tile, room): + in_allowed_room = true + break + # If tile is in a room that's not allowed, stop the ray + if not in_allowed_room: + break + last_world = Vector2(tile.x * FOG_TILE_SIZE + FOG_TILE_SIZE * 0.5, tile.y * FOG_TILE_SIZE + FOG_TILE_SIZE * 0.5) + _mark_seen(seen_map, tile) + if _is_visibility_blocking_tile(tile): + # Stop ray on any wall/closed door + break + elif after_block_tiles > 0: + after_block_tiles += 1 + if after_block_tiles > 3: + break + if FOG_DEBUG_DRAW: + fog_debug_lines.append([player.global_position, last_world]) + +func _mark_seen(seen_map: PackedInt32Array, tile: Vector2i) -> void: + 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 + var idx = tile.x + tile.y * map_size.x + if idx >= 0 and idx < seen_map.size(): + seen_map[idx] = 1 + +func _is_tile_in_room_or_walls(tile: Vector2i, room: Dictionary) -> bool: + if room.is_empty(): + return false + # Room bounds including 2-tile walls: room.x..room.x+room.w, room.y..room.y+room.h + return tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h + +func _mark_room_explored(room: Dictionary) -> void: + if room.is_empty(): + return + var map_size = dungeon_data.map_size + # Include 2-tile wall thickness around the room + for x in range(room.x - 2, room.x + room.w + 2): + for y in range(room.y - 2, room.y + room.h + 2): + if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: + continue + var idx = x + y * map_size.x + if idx >= 0 and idx < explored_map.size(): + explored_map[idx] = 1 + +func _mark_room_visible(room: Dictionary) -> void: + if room.is_empty(): + return + var map_size = dungeon_data.map_size + # Include 2-tile wall thickness around the room + for x in range(room.x - 2, room.x + room.w + 2): + for y in range(room.y - 2, room.y + room.h + 2): + if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: + continue + var idx = x + y * map_size.x + if idx >= 0 and idx < combined_seen.size(): + combined_seen[idx] = 1 + +func _find_room_at_tile(tile: Vector2i) -> Dictionary: + if dungeon_data.is_empty() or not dungeon_data.has("rooms"): + return {} + for room in dungeon_data.rooms: + if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h: + return room + return {} + +func _get_room_index_for_tile(tile: Vector2i) -> int: + if dungeon_data.is_empty() or not dungeon_data.has("rooms"): + return -1 + for i in range(dungeon_data.rooms.size()): + var room = dungeon_data.rooms[i] + if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h: + return i + return -1 + +func _get_view_angle_weight(view_dir: Vector2, ray_dir: Vector2) -> float: + if view_dir.length() < 0.1 or ray_dir.length() < 0.1: + return 1.0 + var dot = clamp(view_dir.normalized().dot(ray_dir.normalized()), -1.0, 1.0) + # Map dot (-1..1) -> weight (0..1) + return (dot + 1.0) * 0.5 + +func _get_player_view_dir(player: Node) -> Vector2: + if player and "current_direction" in player: + match player.current_direction: + 0: + return Vector2.RIGHT + 1: + return Vector2.DOWN + 2: + return Vector2.LEFT + 3: + return Vector2.UP + 4: + return Vector2(1, 1) + 5: + return Vector2(-1, 1) + 6: + return Vector2(-1, -1) + 7: + return Vector2(1, -1) + return Vector2.RIGHT + +func _is_visibility_blocking_tile(tile: Vector2i) -> bool: + # Walls block vision + if dungeon_data.grid[tile.x][tile.y] == 0: + return true + + # Closed doors block vision + var entities_node = get_node_or_null("Entities") + if entities_node: + for door in entities_node.get_children(): + if door.is_in_group("blocking_door"): + if "is_closed" in door and door.is_closed: + var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE)) + if door_tile == tile: + return true + return false + +func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array: + var map_size = dungeon_data.map_size + var mask = _create_seen_array(map_size) + if start_tile.x < 0 or start_tile.y < 0 or start_tile.x >= map_size.x or start_tile.y >= map_size.y: + return mask + + var queue: Array = [] + queue.append(start_tile) + mask[start_tile.x + start_tile.y * map_size.x] = 1 + + var max_steps = 10 # Reduced from 24 to prevent corridor branches from reaching far rooms + while queue.size() > 0: + var tile = queue.pop_front() + var dist = abs(tile.x - start_tile.x) + abs(tile.y - start_tile.y) + if dist > max_steps: + continue + var neighbors = [ + Vector2i(tile.x + 1, tile.y), + Vector2i(tile.x - 1, tile.y), + Vector2i(tile.x, tile.y + 1), + Vector2i(tile.x, tile.y - 1) + ] + for n in neighbors: + if n.x < 0 or n.y < 0 or n.x >= map_size.x or n.y >= map_size.y: + continue + var idx = n.x + n.y * map_size.x + if mask[idx] == 1: + continue + # Only traverse walkable tiles (non-wall), and stop at closed doors + if dungeon_data.grid[n.x][n.y] == 0: + continue + if _is_door_tile(n) and _is_visibility_blocking_tile(n): + continue + mask[idx] = 1 + queue.append(n) + + return mask + +func _get_rooms_connected_to_corridor(corridor_mask: PackedInt32Array, player_tile: Vector2i) -> Array: + var rooms = [] + if dungeon_data.is_empty() or not dungeon_data.has("rooms") or player_tile.x < 0: + return rooms + var map_size = dungeon_data.map_size + # Only check rooms within a small distance of the player (8 tiles) + var max_room_distance = 8 + var room_distances = [] + for room in dungeon_data.rooms: + # Calculate room center distance from player - skip if too far + var room_center = Vector2i(room.x + room.w / 2, room.y + room.h / 2) + var room_dist = abs(room_center.x - player_tile.x) + abs(room_center.y - player_tile.y) + if room_dist > max_room_distance * 2: + continue + + var touches_corridor = false + var closest_corridor_dist = 999999 + # Only check room perimeter (walls and door areas) within close range of player + for x in range(room.x - 2, room.x + room.w + 2): + for y in range(room.y - 2, room.y + room.h + 2): + if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: + continue + # Only check tiles near the player (within max_room_distance) + var tile_dist = abs(x - player_tile.x) + abs(y - player_tile.y) + if tile_dist > max_room_distance: + continue + # Check if this tile is adjacent to a corridor tile in the mask + var neighbors = [ + Vector2i(x + 1, y), + Vector2i(x - 1, y), + Vector2i(x, y + 1), + Vector2i(x, y - 1) + ] + for n in neighbors: + if n.x < 0 or n.y < 0 or n.x >= map_size.x or n.y >= map_size.y: + continue + var n_dist = abs(n.x - player_tile.x) + abs(n.y - player_tile.y) + if n_dist > max_room_distance: + continue + var n_idx = n.x + n.y * map_size.x + if n_idx >= 0 and n_idx < corridor_mask.size() and corridor_mask[n_idx] == 1: + # Check if n is actually a corridor tile (grid value 3) or a door (grid value 2) + if dungeon_data.grid[n.x][n.y] == 3 or dungeon_data.grid[n.x][n.y] == 2: + touches_corridor = true + if n_dist < closest_corridor_dist: + closest_corridor_dist = n_dist + break + if touches_corridor: + break + if touches_corridor: + break + if touches_corridor: + room_distances.append({"room": room, "dist": closest_corridor_dist}) + + # Sort by distance and return only the 2 closest rooms + room_distances.sort_custom(func(a, b): return a.dist < b.dist) + for i in range(min(2, room_distances.size())): + rooms.append(room_distances[i].room) + + return rooms + +func _is_door_tile(tile: Vector2i) -> bool: + var entities_node = get_node_or_null("Entities") + if not entities_node: + return false + for door in entities_node.get_children(): + if door.is_in_group("blocking_door"): + var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE)) + if door_tile == tile: + return true + return false + func _generate_dungeon(): print("GameWorld: _generate_dungeon() called - is_server: ", multiplayer.is_server(), ", has_peer: ", multiplayer.has_multiplayer_peer()) @@ -1031,7 +1895,11 @@ func _generate_dungeon(): # Load HUD after dungeon generation completes (non-blocking) call_deferred("_load_hud") - # Sync dungeon to all clients + # Pack entire dungeon into blob and chunk it (server only) + if multiplayer.is_server(): + _pack_dungeon_blob() + + # Sync dungeon to all clients using blob sync if multiplayer.has_multiplayer_peer(): # Get host's current room for spawning new players near host var host_room = _get_host_room() @@ -1042,7 +1910,12 @@ func _generate_dungeon(): else: LogManager.log("GameWorld: WARNING: Server dungeon_data has NO 'enemies' key before sync!", LogManager.CATEGORY_DUNGEON) - _rpc_to_ready_peers("_sync_dungeon", [dungeon_data, dungeon_seed, current_level, host_room]) + # Use blob sync for all connected peers + var peers = multiplayer.get_peers() + for peer_id in peers: + if peer_id != 1: # Don't sync to self (server is peer 1) + print("GameWorld: HOST - Syncing new level to peer ", peer_id, " using blob sync") + _sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, host_room) LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON) @@ -1227,6 +2100,7 @@ func _render_dungeon(): LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Dungeon rendered on TileMapLayer", LogManager.CATEGORY_DUNGEON) + _init_fog_of_war() # Create stairs Area2D if stairs data exists _create_stairs_area() @@ -1421,22 +2295,500 @@ func _get_host_room() -> Dictionary: var host_pos = host_players[0].position return _find_room_at_position(host_pos) -func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary): - # Helper function to send dungeon sync RPC to a client +func _check_peer_recognized(peer_id: int) -> bool: + # Check if peer is recognized in multiplayer system and data channels are open + if not multiplayer.is_server(): + return false + + var peers = multiplayer.get_peers() + var is_in_peers = (peer_id in peers) + + # First check if peer is in get_peers() and has "connected" flag set to true + # The "connected" flag is true when all three data channels (reliable, unreliable, ordered) are open + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + var has_peer = webrtc_peer.has_peer(peer_id) + + if has_peer: + var peer_info = webrtc_peer.get_peer(peer_id) + if peer_info: + # Log peer info keys for debugging (only once per peer) + var log_key = "peer_" + str(peer_id) + "_keys_logged" + if not has_meta(log_key): + set_meta(log_key, true) + LogManager.log("GameWorld: Peer " + str(peer_id) + " peer_info keys: " + str(peer_info.keys()), LogManager.CATEGORY_NETWORK) + + var is_net_connected = peer_info.get("connected", false) + # On web, peer_info has "connection" key (not "connection_state") + # WebRTCPeerConnection.ConnectionState: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + # Note: "connection" might be an enum object (WebRTCPeerConnection.ConnectionState), not an int + var connection_obj = peer_info.get("connection") + var connection_val: int = -1 + if connection_obj != null: + # Try to get int value from enum (it might be an enum or already an int) + var connection_obj_type = typeof(connection_obj) + if connection_obj_type == TYPE_INT: + connection_val = int(connection_obj) + else: + # If it's an enum object, we can't easily get the int value on web + # Just use -1 so we don't try to compare it + connection_val = -1 + var channels = peer_info.get("channels", {}) + + # Check individual data channel states (hypothesis B - channels might not all be open) + var channel_states = [] + if channels is Array: + for i in range(channels.size()): + var channel = channels[i] + if channel != null: + # Try to get ready_state from channel (WebRTCDataChannel has ready_state property) + var channel_ready_state = -1 + if channel.has_method("get_ready_state"): + channel_ready_state = channel.get_ready_state() + else: + # Try accessing as property + channel_ready_state = channel.get("ready_state") if "ready_state" in channel else -1 + channel_states.append(channel_ready_state) + else: + channel_states.append(-1) + + # Try to get connection state from matchbox_client peer_connections (hypothesis D - alternative access) + var matchbox_connection_state = -1 + var matchbox_signaling_state = -1 + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + # peer_connections is a Dictionary: peer_id (int) -> WebRTCPeerConnection + if "peer_connections" in matchbox: + var peer_conns = matchbox.peer_connections + if peer_conns and peer_conns.has(peer_id): + var pc = peer_conns[peer_id] + if pc: + if pc.has_method("get_connection_state"): + matchbox_connection_state = pc.get_connection_state() + if pc.has_method("get_signaling_state"): + matchbox_signaling_state = pc.get_signaling_state() + + # Log peer info for debugging (only once per peer to reduce spam) + var log_key2 = "peer_" + str(peer_id) + "_recognized_logged" + if not has_meta(log_key2): + set_meta(log_key2, true) + LogManager.log("GameWorld: Peer " + str(peer_id) + " info - connected: " + str(is_net_connected) + ", connection: " + str(connection_val) + ", matchbox_conn: " + str(matchbox_connection_state) + ", matchbox_sig: " + str(matchbox_signaling_state) + ", on_web: " + str(OS.get_name() == "Web"), LogManager.CATEGORY_NETWORK) + + if is_net_connected and is_in_peers: + LogManager.log("GameWorld: Peer " + str(peer_id) + " is connected (WebRTC - data channels open) and in get_peers()", LogManager.CATEGORY_NETWORK) + return true + + # Log Matchbox connection state for debugging, but don't use it for RPC readiness + # RPCs will fail if peer isn't in get_peers(), regardless of Matchbox connection state + if OS.get_name() == "Web" and matchbox_signaling_state == 0 and matchbox_connection_state >= 1: + LogManager.log("GameWorld: Peer " + str(peer_id) + " Matchbox connection is ready (signaling: STABLE, connection: " + str(matchbox_connection_state) + ") but NOT in get_peers() yet - will retry", LogManager.CATEGORY_NETWORK) + + # On web, even if "connected" is false, if "connection" is CONNECTED (2), log for debugging + # but still require is_in_peers to be true for RPC readiness + if OS.get_name() == "Web" and connection_val == 2 and not is_in_peers: + LogManager.log("GameWorld: Peer " + str(peer_id) + " connection value is CONNECTED (2) on web but NOT in get_peers() yet - will retry", LogManager.CATEGORY_NETWORK) + # Peer exists but not ready - return false without logging (too spammy) + else: + # Log once if peer_info is null + var log_key = "peer_" + str(peer_id) + "_null_info_logged" + if not has_meta(log_key): + set_meta(log_key, true) + LogManager.log("GameWorld: Peer " + str(peer_id) + " exists in WebRTC but peer_info is null", LogManager.CATEGORY_NETWORK) + else: + # Log once if peer doesn't exist in WebRTC + var log_key = "peer_" + str(peer_id) + "_not_in_webrtc_logged" + if not has_meta(log_key): + set_meta(log_key, true) + LogManager.log("GameWorld: Peer " + str(peer_id) + " not found in WebRTCMultiplayerPeer", LogManager.CATEGORY_NETWORK) + + # CRITICAL: Only return true if peer is in get_peers() - this is required for RPCs to work + # Godot's rpc_id() will fail with "unknown peer ID" if peer isn't in get_peers(), regardless of Matchbox connection state + if is_in_peers: + LogManager.log("GameWorld: Peer " + str(peer_id) + " found in get_peers() - RPC ready", LogManager.CATEGORY_NETWORK) + return true + + # Peer not ready - don't spam logs, just return false + + return false + +func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary, retry_count: int = 0): + # Helper function to send dungeon sync RPC to a client with retry logic # Called via call_deferred to ensure the peer is registered in the multiplayer system if multiplayer.is_server(): - _sync_dungeon.rpc_id(client_peer_id, dungeon_data_sync, seed_value, level, host_room) + # Prevent multiple simultaneous syncs to the same peer + if dungeon_sync_in_progress.get(client_peer_id, false): + print("GameWorld: HOST - Dungeon sync already in progress for peer ", client_peer_id, ", skipping duplicate sync") + LogManager.log("GameWorld: Dungeon sync already in progress for peer " + str(client_peer_id) + ", skipping", LogManager.CATEGORY_NETWORK) + return + + # Mark sync as in progress + dungeon_sync_in_progress[client_peer_id] = true + + # Check if peer is recognized before sending RPC + var is_recognized = _check_peer_recognized(client_peer_id) + var peers = multiplayer.get_peers() + var is_in_peers = (client_peer_id in peers) + + # On web, RPCs will fail with "unknown peer ID" if peer isn't in get_peers() + # So we MUST wait until the peer is in get_peers() before sending RPCs + # Check if peer is in get_peers() - this is the only reliable check for RPC readiness + if not is_in_peers: + # Reduced retries - don't retry so many times for host + var max_retries = 30 if OS.get_name() == "Web" else 20 # 6 seconds on web, 4 seconds otherwise + if retry_count < max_retries: + if retry_count % 10 == 0: # Log every 10th retry (every 2 seconds) to reduce spam + # Check data channel states for debugging + var channel_info = "" + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if webrtc_peer.has_peer(client_peer_id): + var peer_info = webrtc_peer.get_peer(client_peer_id) + if peer_info: + var is_net_connected = peer_info.get("connected", false) + var channels = peer_info.get("channels", {}) + channel_info = " (connected: " + str(is_net_connected) + ", channels: " + str(channels.size()) + ")" + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() yet, retrying dungeon sync (" + str(retry_count + 1) + "/" + str(max_retries) + ")" + channel_info, LogManager.CATEGORY_NETWORK) + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_dungeon_to_client(client_peer_id, dungeon_data_sync, seed_value, level, host_room, retry_count + 1) + ) + return + else: + # After max retries, give up and log error + LogManager.log_error("GameWorld: Max retries (" + str(max_retries) + ") reached for peer " + str(client_peer_id) + ", giving up on dungeon sync", LogManager.CATEGORY_NETWORK) + dungeon_sync_in_progress.erase(client_peer_id) # Clear sync flag + return + + LogManager.log("GameWorld: Sending dungeon sync RPC to peer " + str(client_peer_id) + " (is_in_peers: " + str(is_in_peers) + ", is_recognized: " + str(is_recognized) + ")", LogManager.CATEGORY_NETWORK) + print("=== GameWorld: HOST - Sending dungeon blob sync to peer ", client_peer_id, " ===") + # Peer is in get_peers(), safe to send RPC + # Use the pre-packed blob chunks for efficient sending + if dungeon_blob_chunks.is_empty(): + print("GameWorld: HOST - WARNING: No blob chunks available, packing now...") + _pack_dungeon_blob() + call_deferred("_send_dungeon_blob_sync", client_peer_id) -@rpc("authority", "reliable") +func _send_dungeon_blob_sync(client_peer_id: int): + # Send pre-packed dungeon blob chunks with acknowledgment-based flow control + if not is_inside_tree() or not multiplayer.is_server(): + return + + if dungeon_blob_chunks.is_empty(): + print("GameWorld: HOST - ERROR: No dungeon blob chunks available!") + return + + var total_chunks = dungeon_blob_chunks.size() + print("GameWorld: HOST - Starting dungeon blob sync to peer ", client_peer_id, " (", total_chunks, " chunks)") + + # Send metadata first + var metadata = dungeon_blob_metadata + print("GameWorld: HOST - [CHUNK 0] Sending metadata...") + _sync_dungeon_blob_metadata.rpc_id(client_peer_id, metadata.seed, metadata.level, metadata.map_size, metadata.host_room, total_chunks) + + # Initialize acknowledgment tracking + var chunk_acks = {} + for i in range(total_chunks): + chunk_acks[i] = false + + dungeon_chunk_acks[client_peer_id] = { + "chunks": dungeon_blob_chunks.duplicate(), # Copy of chunks + "acks": chunk_acks, + "next_chunk": 0, + "total_chunks": total_chunks + } + +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 + # Send the actual RPC - called deferred to avoid buffer issues + # Check buffer before sending to prevent "Buffer full! Dropping data" errors + if not is_inside_tree() or not multiplayer.is_server(): + return + + # Check WebRTC data channel buffer before sending large RPC + # According to Godot docs, get_buffered_amount() returns bytes queued + # Typical buffer size is 16MB, but we should keep it under 1MB to be safe + var max_buffer_size = 1024 * 1024 # 1MB threshold + var buffer_ok = true + var buffered_amount = 0 + + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if webrtc_peer.has_peer(client_peer_id): + var peer_info = webrtc_peer.get_peer(client_peer_id) + if peer_info: + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + # Check the reliable channel (usually first one) + for channel in channels: + if channel != null and channel.has_method("get_buffered_amount"): + buffered_amount = channel.get_buffered_amount() + if buffered_amount > max_buffer_size: + buffer_ok = false + break + + if not buffer_ok: + # Buffer is too full, wait and retry + var max_retries = 50 # Wait up to 10 seconds (50 * 0.2s) + if retry_count < max_retries: + if retry_count % 10 == 0: # Log every 2 seconds + print("GameWorld: HOST - Buffer too full (", buffered_amount, " bytes), waiting before sending dungeon sync to peer ", client_peer_id, " (retry ", retry_count + 1, "/", max_retries, ")") + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _send_sync_dungeon_rpc(client_peer_id, dungeon_data_sync, seed_value, level, host_room, retry_count + 1) + ) + return + else: + print("GameWorld: HOST - ERROR: Max retries reached for dungeon sync to peer ", client_peer_id, " (buffer still full)") + return + + # Chunk dungeon data into <16KB pieces to avoid WebRTC buffer overflow + # WebRTC has a 64KB message limit, but we'll use 16KB chunks for safety + var tile_grid = dungeon_data_sync.get("tile_grid", []) + var grid = dungeon_data_sync.get("grid", []) + var map_size = dungeon_data_sync.get("map_size", Vector2i(72, 72)) + var start_room = dungeon_data_sync.get("start_room", {}) + var rooms = dungeon_data_sync.get("rooms", []) + + var non_essential_data = { + "enemies": dungeon_data_sync.get("enemies", []), + "torches": dungeon_data_sync.get("torches", []), + "interactable_objects": dungeon_data_sync.get("interactable_objects", []) + } + + # Calculate chunk size: send 10 rows at a time (~10KB per chunk, well under 16KB limit) + # Each row: map_size.x * 8 bytes (tile_grid Vector2i) + map_size.x * 4 bytes (grid int) = map_size.x * 12 bytes + # 10 rows = 10 * map_size.x * 12 bytes + overhead = ~10KB per chunk (for 72x72, scales with larger maps) + # We use 10 rows to keep chunks well under 16KB WebRTC limit + const ROWS_PER_CHUNK = 10 + var total_rows = map_size.y + var total_chunks = ceil(float(total_rows) / float(ROWS_PER_CHUNK)) + + # Calculate actual chunk size for logging + var bytes_per_row = map_size.x * 12 # 8 bytes Vector2i + 4 bytes int + var chunk_size_bytes = ROWS_PER_CHUNK * bytes_per_row + print("GameWorld: HOST - Chunk calculation: ", map_size.x, " cols * 12 bytes/row = ", bytes_per_row, " bytes/row") + print("GameWorld: HOST - 10 rows per chunk = ", chunk_size_bytes, " bytes (~", chunk_size_bytes / 1024, "KB) per chunk") + + print("GameWorld: HOST - Starting chunked dungeon sync to peer ", client_peer_id) + print("GameWorld: HOST - Map size: ", map_size, ", Total rows: ", total_rows, ", Chunks: ", total_chunks) + print("GameWorld: HOST - Buffer status: ", buffered_amount, " bytes") + + # Step 1: Send metadata first (map_size, seed, level, host_room) - this is tiny + print("GameWorld: HOST - [CHUNK 0/", total_chunks + 2, "] Sending metadata...") + _sync_dungeon_metadata.rpc_id(client_peer_id, map_size, seed_value, level, host_room) + + # Step 2: Prepare all chunks and store them for acknowledgment-based sending + # Initialize acknowledgment tracking for this peer + var chunks_data = [] + var chunk_acks = {} + for chunk_idx in range(total_chunks): + chunk_acks[chunk_idx] = false + var start_row = chunk_idx * ROWS_PER_CHUNK + var end_row = min(start_row + ROWS_PER_CHUNK, total_rows) + + # Extract chunk: for each column x, extract rows start_row to end_row + var tile_grid_chunk = [] + var grid_chunk = [] + for x in range(map_size.x): + var tile_col_chunk = [] + var grid_col_chunk = [] + for y in range(start_row, end_row): + if x < tile_grid.size() and y < tile_grid[x].size(): + tile_col_chunk.append(tile_grid[x][y]) + grid_col_chunk.append(grid[x][y]) + tile_grid_chunk.append(tile_col_chunk) + grid_chunk.append(grid_col_chunk) + + chunks_data.append({ + "chunk_idx": chunk_idx, + "start_row": start_row, + "end_row": end_row, + "tile_grid": tile_grid_chunk, + "grid": grid_chunk, + "size": _estimate_chunk_size(tile_grid_chunk, grid_chunk) + }) + + # Store chunk data and tracking for this peer + dungeon_chunk_acks[client_peer_id] = { + "chunks": chunks_data, + "acks": chunk_acks, + "next_chunk": 0, # Start with chunk 0 (metadata is sent immediately) + "total_chunks": total_chunks, + "start_room": start_room, + "rooms": rooms, + "entities": non_essential_data + } + + # Wait for metadata acknowledgment before sending first chunk + # Client will send ack for metadata (chunk -1), then we send chunk 0 + + # Step 3 & 4: Rooms and entities will be sent after all tile chunks are acknowledged + +@rpc("any_peer", "reliable") +func _ack_dungeon_chunk(chunk_idx: int): + # Server receives acknowledgment from client + if not multiplayer.is_server(): + return + + var sender_id = multiplayer.get_remote_sender_id() + if not dungeon_chunk_acks.has(sender_id): + print("GameWorld: HOST - Received ack for chunk ", chunk_idx, " from peer ", sender_id, " but no sync in progress") + return + + var sync_data = dungeon_chunk_acks[sender_id] + var acks = sync_data.acks + var total_chunks = sync_data.total_chunks + + # Mark chunk as acknowledged + if chunk_idx == -1: + # Metadata acknowledgment - send first blob chunk + print("GameWorld: HOST - [CHUNK 0] Metadata acknowledged by peer ", sender_id, ", sending first blob chunk...") + _send_next_chunk(sender_id) + elif chunk_idx >= 0 and chunk_idx < total_chunks: + # Blob chunk acknowledgment + acks[chunk_idx] = true + print("GameWorld: HOST - [CHUNK ", chunk_idx + 1, "/", total_chunks + 1, "] Acknowledged by peer ", sender_id) + _send_next_chunk(sender_id) + +func _send_next_chunk(peer_id: int): + # Send the next unacknowledged chunk to the peer + if not dungeon_chunk_acks.has(peer_id): + return + + var sync_data = dungeon_chunk_acks[peer_id] + var chunks = sync_data.chunks + var acks = sync_data.acks + var next_chunk = sync_data.next_chunk + var total_chunks = sync_data.total_chunks + + # Find next unacknowledged chunk + while next_chunk < total_chunks: + if not acks.get(next_chunk, false): + # Check if this is blob chunks (PackedByteArray) or old tile chunks (Dictionary) + var chunk_data = chunks[next_chunk] + if chunk_data is PackedByteArray: + # New blob chunk system + print("GameWorld: HOST - [CHUNK ", next_chunk + 1, "/", total_chunks + 1, "] Sending blob chunk (", chunk_data.size(), " bytes)") + _sync_dungeon_blob_chunk.rpc_id(peer_id, next_chunk, chunk_data) + else: + # Old tile chunk system (backwards compatibility) + print("GameWorld: HOST - [CHUNK ", next_chunk + 1, "/", total_chunks + 2, "] Sending rows ", chunk_data.start_row, "-", chunk_data.end_row - 1, " (", chunk_data.size, " bytes)") + _sync_dungeon_chunk.rpc_id(peer_id, chunk_data.chunk_idx, chunk_data.start_row, chunk_data.end_row, chunk_data.tile_grid, chunk_data.grid) + sync_data.next_chunk = next_chunk + 1 + return + next_chunk += 1 + + # All chunks acknowledged + if next_chunk >= total_chunks: + # Check if this is blob system (no rooms/entities to send separately) + if chunks.size() > 0 and chunks[0] is PackedByteArray: + # Blob system - all data is in the blob, sync complete + print("GameWorld: HOST - All dungeon blob chunks acknowledged by peer ", peer_id, ", sync complete!") + dungeon_chunk_acks.erase(peer_id) + dungeon_sync_in_progress.erase(peer_id) + else: + # Old system - send rooms + print("GameWorld: HOST - [CHUNK ", total_chunks + 1, "/", total_chunks + 2, "] All tile chunks acknowledged, sending rooms data...") + _sync_dungeon_rooms.rpc_id(peer_id, sync_data.start_room, sync_data.rooms) + +func _pack_dungeon_blob(): + # Pack entire dungeon data into a blob and chunk it for efficient sending + # This is done once when dungeon is generated, then chunks are reused for all joiners + if not multiplayer.is_server(): + return + + if dungeon_data.is_empty(): + print("GameWorld: HOST - Cannot pack dungeon blob: dungeon_data is empty") + return + + # Clear old chunks before packing new ones (important for level transitions) + dungeon_blob_chunks.clear() + dungeon_blob_metadata.clear() + + # Collect ALL dungeon data into a single dictionary + var full_dungeon_data = { + "tile_grid": dungeon_data.get("tile_grid", []), + "grid": dungeon_data.get("grid", []), + "map_size": dungeon_data.get("map_size", Vector2i(72, 72)), + "rooms": dungeon_data.get("rooms", []), + "start_room": dungeon_data.get("start_room", {}), + "exit_room": dungeon_data.get("exit_room", {}), + "doors": dungeon_data.get("doors", []), + "torches": dungeon_data.get("torches", []), + "enemies": dungeon_data.get("enemies", []), + "interactable_objects": dungeon_data.get("interactable_objects", []), + "stairs": dungeon_data.get("stairs", {}), + "blocking_doors": dungeon_data.get("blocking_doors", []) + } + + # Log what's being packed + var enemy_count = full_dungeon_data.enemies.size() if full_dungeon_data.enemies is Array else 0 + var torch_count = full_dungeon_data.torches.size() if full_dungeon_data.torches is Array else 0 + var object_count = full_dungeon_data.interactable_objects.size() if full_dungeon_data.interactable_objects is Array else 0 + print("GameWorld: HOST - Packing dungeon blob with: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects") + LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON) + + # Store metadata separately (small, sent first) + dungeon_blob_metadata = { + "seed": dungeon_seed, + "level": current_level, + "map_size": full_dungeon_data.map_size, + "host_room": _get_host_room() + } + + # Serialize to bytes + var blob_bytes = var_to_bytes(full_dungeon_data) + var blob_size = blob_bytes.size() + print("GameWorld: HOST - Packed dungeon blob: ", blob_size, " bytes (", blob_size / 1024, "KB)") + + # Chunk the bytes into <16KB pieces + const MAX_CHUNK_SIZE = 14 * 1024 # 14KB to leave room for overhead + dungeon_blob_chunks.clear() + + var offset = 0 + var chunk_idx = 0 + while offset < blob_size: + var chunk_size = min(MAX_CHUNK_SIZE, blob_size - offset) + var chunk = blob_bytes.slice(offset, offset + chunk_size) + dungeon_blob_chunks.append(chunk) + print("GameWorld: HOST - Created chunk ", chunk_idx, ": ", chunk.size(), " bytes") + offset += chunk_size + chunk_idx += 1 + + print("GameWorld: HOST - Dungeon blob chunked into ", dungeon_blob_chunks.size(), " chunks") + +func _estimate_chunk_size(tile_grid_chunk: Array, grid_chunk: Array) -> int: + # Estimate size of a chunk in bytes + # tile_grid_chunk: Array of columns (x), each column is Array of Vector2i (8 bytes each) + # grid_chunk: Array of columns (x), each column is Array of int (4 bytes each) + var size = 0 + for col in tile_grid_chunk: + if col is Array: + size += col.size() * 8 # Vector2i = 8 bytes + for col in grid_chunk: + if col is Array: + size += col.size() * 4 # int = 4 bytes + size += 1024 # Dictionary/Array overhead + return size + +@rpc("authority", "reliable", "call_local") func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary = {}): # Clients receive dungeon data from host + # Use direct print FIRST to ensure we see this even if buffer is full + print("=== _sync_dungeon RPC RECEIVED on client ===") + print("=== Level: ", level, " ===") if not multiplayer.is_server(): - print("GameWorld: Client received dungeon sync for level ", level) + # Use direct print to avoid buffer issues + print("=== GameWorld: Client received dungeon sync for level ", level, " ===") + LogManager.log("GameWorld: Client received dungeon sync for level " + str(level), LogManager.CATEGORY_DUNGEON) print("GameWorld: dungeon_data_sync keys: ", dungeon_data_sync.keys()) + LogManager.log("GameWorld: dungeon_data_sync keys: " + str(dungeon_data_sync.keys()), LogManager.CATEGORY_DUNGEON) if dungeon_data_sync.has("enemies"): var enemy_count = dungeon_data_sync.enemies.size() if dungeon_data_sync.enemies is Array else 0 + print("GameWorld: dungeon_data_sync has ", enemy_count, " enemies") LogManager.log("GameWorld: dungeon_data_sync has " + str(enemy_count) + " enemies", LogManager.CATEGORY_DUNGEON) else: + print("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!") LogManager.log("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!", LogManager.CATEGORY_DUNGEON) # Check if we're reconnecting to the same level - skip dungeon reload if so @@ -1451,6 +2803,10 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h current_level = level # Update current_level FIRST before showing level number LogManager.log("GameWorld: Client updated current_level to " + str(current_level) + " from sync", LogManager.CATEGORY_DUNGEON) + # CRITICAL: Re-randomize appearance for all existing players now that we have dungeon_seed + # Players spawned before dungeon_seed was received used Time.get_ticks_msec() which causes different appearance + _fix_player_appearance_after_dungeon_sync() + # Skip dungeon reload if reconnecting to same level if skip_reload: # Just update the level number, don't reload the dungeon @@ -1468,49 +2824,562 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h await get_tree().process_frame # Wait an extra frame to ensure cleanup is complete # Render dungeon on client + print("GameWorld: Client - Rendering dungeon...") _render_dungeon() + print("GameWorld: Client - Dungeon rendered") # Spawn torches on client + print("GameWorld: Client - Spawning torches...") _spawn_torches() + print("GameWorld: Client - Torches spawned") # Spawn enemies on client + print("GameWorld: Client - Spawning enemies...") _spawn_enemies() + print("GameWorld: Client - Enemies spawned") # Spawn interactable objects on client + print("GameWorld: Client - Spawning interactable objects...") _spawn_interactable_objects() + print("GameWorld: Client - Interactable objects spawned") # Spawn blocking doors on client + print("GameWorld: Client - Spawning blocking doors...") _spawn_blocking_doors() + print("GameWorld: Client - Blocking doors spawned") # Spawn room triggers on client + print("GameWorld: Client - Spawning room triggers...") _spawn_room_triggers() + print("GameWorld: Client - Room triggers spawned") # Wait a frame to ensure all enemies and objects are properly added to scene tree and initialized await get_tree().process_frame await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready # Update spawn points - use host's room if available, otherwise use start room + print("GameWorld: Client - Updating spawn points...") if not host_room.is_empty(): + print("GameWorld: Client - Using host's room for spawn points: ", host_room) LogManager.log("GameWorld: Using host's room for spawn points", LogManager.CATEGORY_DUNGEON) _update_spawn_points(host_room) # Move any existing players to spawn near host _move_players_to_host_room(host_room) else: + print("GameWorld: Client - Host room not available, using start room") LogManager.log("GameWorld: Host room not available, using start room", LogManager.CATEGORY_DUNGEON) _update_spawn_points() # Move all players to start room + print("GameWorld: Client - Moving all players to start room...") _move_all_players_to_start_room() + print("GameWorld: Client - Players moved to start room") + + # CRITICAL: Update camera immediately after moving players to ensure joiner can see the game + # Wait a frame to ensure players are fully positioned + await get_tree().process_frame + print("GameWorld: Client - Updating camera...") + _update_camera() + print("GameWorld: Client - Camera updated") # Note: Level number is shown via _sync_show_level_number RPC, not here # This prevents duplicate displays and ensures consistent timing # Load HUD on client (same as server does) + # Use call_deferred to ensure it runs after all dungeon spawning is complete + print("=== GameWorld: Client - About to load HUD (call_deferred) ===") + LogManager.log("GameWorld: Client - About to load HUD (call_deferred)", LogManager.CATEGORY_DUNGEON) call_deferred("_load_hud") + # Also ensure HUD loads after a short delay as backup + get_tree().create_timer(0.2).timeout.connect(func(): + var hud_check = get_node_or_null("IngameHUD") + if not hud_check or not is_instance_valid(hud_check): + print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===") + LogManager.log("GameWorld: Client - HUD not found after deferred call, loading directly as backup", LogManager.CATEGORY_DUNGEON) + _load_hud() + else: + print("GameWorld: Client - HUD already loaded (found in scene tree)") + ) + print("=== GameWorld: Client - _sync_dungeon() completed ===") - # Sync existing dungeon to newly connected clients - if multiplayer.is_server(): - # This shouldn't happen, but just in case - pass + # CRITICAL: Fix appearance for players spawned before dungeon_seed was available + # This ensures all players match the host's appearance + call_deferred("_fix_player_appearance_after_dungeon_sync") + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vector2i, host_room: Dictionary, total_chunks: int): + # Client receives metadata for blob sync + print("=== _sync_dungeon_blob_metadata RPC RECEIVED on client ===") + print("=== [CHUNK 0] Client received blob metadata - Level: ", level, ", Map size: ", map_size_sync, ", Total chunks: ", total_chunks, " ===") + if not multiplayer.is_server(): + # Check if this is a new level (different from current) + var is_new_level = (current_level != level) + if is_new_level: + print("GameWorld: Client - New level detected (", current_level, " -> ", level, "), clearing old dungeon data") + # Clear old dungeon data for level transition + dungeon_data.clear() + # Clear any ongoing syncs + dungeon_sync_in_progress.clear() + + # Reset blob sync state + dungeon_sync_metadata = { + "map_size": map_size_sync, + "seed": seed_value, + "level": level, + "host_room": host_room + } + dungeon_sync_chunks.clear() + dungeon_sync_received_chunks = 0 + dungeon_sync_complete = false + dungeon_sync_total_chunks = total_chunks + + # Initialize blob data storage + if not dungeon_sync_chunks.has("blob_data"): + dungeon_sync_chunks["blob_data"] = {} + for i in range(total_chunks): + dungeon_sync_chunks["blob_data"][i] = null + + dungeon_seed = seed_value + current_level = level + + print("GameWorld: Client - [CHUNK 0] Blob metadata received - expecting ", total_chunks, " blob chunks") + + # Check if reconnecting to same level + if network_manager and network_manager.reconnection_level > 0: + if network_manager.reconnection_level == level: + LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON) + if network_manager: + network_manager.reconnection_level = 0 + dungeon_sync_complete = true + return + + # Clear previous level + _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) + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int, host_room: Dictionary = {}): + # OLD METHOD - kept for backwards compatibility + # Client receives metadata first - initializes chunked sync + print("=== _sync_dungeon_metadata RPC RECEIVED on client ===") + print("=== [CHUNK 0] Client received metadata - Level: ", level, ", Map size: ", map_size_sync, " ===") + if not multiplayer.is_server(): + # Send acknowledgment for metadata (chunk -1) + _ack_dungeon_chunk.rpc_id(1, -1) # Send to server (peer 1) + # If we're already syncing and this is for the same level, ignore duplicate metadata + if not dungeon_sync_complete and dungeon_sync_metadata.has("level"): + var existing_level = dungeon_sync_metadata.get("level", 0) + if existing_level == level: + print("GameWorld: Client - [CHUNK 0] WARNING: Received duplicate metadata for level ", level, ", ignoring") + return + + # Reset chunk sync state + dungeon_sync_metadata = { + "map_size": map_size_sync, + "seed": seed_value, + "level": level, + "host_room": host_room + } + dungeon_sync_chunks.clear() + dungeon_sync_rooms.clear() + dungeon_sync_received_chunks = 0 + dungeon_sync_complete = false + + # Calculate expected chunk count (10 rows per chunk) + var total_rows = map_size_sync.y + dungeon_sync_total_chunks = ceil(float(total_rows) / 10.0) + + dungeon_seed = seed_value + current_level = level + + print("GameWorld: Client - [CHUNK 0] Metadata received - Map size: ", map_size_sync, ", Level: ", level) + print("GameWorld: Client - [CHUNK 0] Expecting ", dungeon_sync_total_chunks, " tile chunks (", total_rows, " rows total)") + var bytes_per_row = map_size_sync.x * 12 # 8 bytes Vector2i + 4 bytes int + var chunk_size_bytes = 10 * bytes_per_row + print("GameWorld: Client - [CHUNK 0] Chunk size: ~", chunk_size_bytes / 1024, "KB per chunk (", map_size_sync.x, " cols * 12 bytes/row * 10 rows)") + LogManager.log("GameWorld: Client received dungeon metadata for level " + str(level) + " (map size: " + str(map_size_sync) + ")", LogManager.CATEGORY_DUNGEON) + + # Check if reconnecting to same level + if network_manager and network_manager.reconnection_level > 0: + if network_manager.reconnection_level == level: + LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON) + if network_manager: + network_manager.reconnection_level = 0 + dungeon_sync_complete = true + return + + # Clear previous level + _clear_level() + await get_tree().process_frame + await get_tree().process_frame + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_blob_chunk(chunk_idx: int, chunk_bytes: PackedByteArray): + # Client receives a blob chunk + print("=== _sync_dungeon_blob_chunk RPC RECEIVED on client ===") + print("=== [CHUNK ", chunk_idx + 1, "] Client received blob chunk (", chunk_bytes.size(), " bytes) ===") + if not multiplayer.is_server(): + # 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 + + print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)") + + # Send acknowledgment to server + _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) + + # Check if all chunks received + if dungeon_sync_received_chunks >= dungeon_sync_total_chunks: + print("GameWorld: Client - All blob chunks received! Reassembling and unpacking...") + _reassemble_dungeon_blob() + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_chunk(chunk_idx: int, start_row: int, end_row: int, tile_grid_chunk: Array, grid_chunk: Array): + # OLD METHOD - kept for backwards compatibility + # Client receives a chunk of tile_grid and grid rows + print("=== _sync_dungeon_chunk RPC RECEIVED on client ===") + print("=== [CHUNK ", chunk_idx + 1, "] Client received rows ", start_row, "-", end_row - 1, " ===") + if not multiplayer.is_server(): + # Store chunk + dungeon_sync_chunks[chunk_idx] = { + "start_row": start_row, + "end_row": end_row, + "tile_grid": tile_grid_chunk, + "grid": grid_chunk + } + dungeon_sync_received_chunks += 1 + + print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)") + + # Send acknowledgment to server + _ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1) + + # Check if all chunks received (will be triggered by _sync_dungeon_rooms) + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_rooms(start_room_sync: Dictionary, rooms_sync: Array): + # Client receives rooms data - triggers dungeon assembly and rendering + print("=== _sync_dungeon_rooms RPC RECEIVED on client ===") + print("=== [CHUNK ", dungeon_sync_total_chunks + 1, "] Client received rooms data ===") + if not multiplayer.is_server(): + dungeon_sync_rooms = { + "start_room": start_room_sync, + "rooms": rooms_sync + } + + print("GameWorld: Client - [CHUNK ", dungeon_sync_total_chunks + 1, "] Rooms received - checking if all chunks complete...") + + # Check if we have all chunks + if dungeon_sync_received_chunks < dungeon_sync_total_chunks: + print("GameWorld: Client - WARNING: Received rooms but missing chunks! (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, ")") + # Wait a bit for missing chunks + get_tree().create_timer(0.5).timeout.connect(func(): + if is_inside_tree() and not multiplayer.is_server(): + _check_and_render_dungeon() + ) + else: + # All chunks received, render dungeon + _check_and_render_dungeon() + +func _reassemble_dungeon_blob(): + # Reassemble blob chunks and unpack dungeon data + if dungeon_sync_complete: + return + + if not dungeon_sync_chunks.has("blob_data"): + print("GameWorld: Client - Cannot reassemble: no blob data") + return + + var blob_data = dungeon_sync_chunks["blob_data"] + var total_chunks = dungeon_sync_total_chunks + + # Check if we have all chunks + for i in range(total_chunks): + if not blob_data.has(i) or blob_data[i] == null: + print("GameWorld: Client - Missing blob chunk ", i) + return + + # Reassemble chunks into single byte array + var blob_bytes = PackedByteArray() + for i in range(total_chunks): + blob_bytes.append_array(blob_data[i]) + + print("GameWorld: Client - Reassembled blob: ", blob_bytes.size(), " bytes") + + # 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!") + return + + # Extract dungeon data + dungeon_data = full_dungeon_data + dungeon_seed = dungeon_sync_metadata.seed + current_level = dungeon_sync_metadata.level + + # Log what was unpacked + var enemy_count = dungeon_data.enemies.size() if dungeon_data.has("enemies") and dungeon_data.enemies is Array else 0 + var torch_count = dungeon_data.torches.size() if dungeon_data.has("torches") and dungeon_data.torches is Array else 0 + var object_count = dungeon_data.interactable_objects.size() if dungeon_data.has("interactable_objects") and dungeon_data.interactable_objects is Array else 0 + print("GameWorld: Client - Dungeon blob unpacked! Rendering...") + print("GameWorld: Client - Unpacked data: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects") + print("GameWorld: Client - dungeon_data keys: ", dungeon_data.keys()) + LogManager.log("GameWorld: Client unpacked dungeon blob for level " + str(current_level) + " - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON) + + # Fix player appearance + _fix_player_appearance_after_dungeon_sync() + + # Render dungeon + print("GameWorld: Client - Rendering dungeon from blob...") + _render_dungeon() + print("GameWorld: Client - Dungeon rendered") + + # Spawn all entities + print("GameWorld: Client - Spawning torches from blob...") + _spawn_torches() + print("GameWorld: Client - Torches spawned") + + print("GameWorld: Client - Spawning enemies from blob...") + if dungeon_data.has("enemies"): + print("GameWorld: Client - dungeon_data has enemies: ", dungeon_data.enemies.size(), " enemies") + else: + print("GameWorld: Client - WARNING: dungeon_data has NO 'enemies' key!") + _spawn_enemies() + print("GameWorld: Client - Enemies spawned") + + print("GameWorld: Client - Spawning interactable objects from blob...") + if dungeon_data.has("interactable_objects"): + print("GameWorld: Client - dungeon_data has interactable_objects: ", dungeon_data.interactable_objects.size(), " objects") + else: + print("GameWorld: Client - WARNING: dungeon_data has NO 'interactable_objects' key!") + _spawn_interactable_objects() + print("GameWorld: Client - Interactable objects spawned") + + print("GameWorld: Client - Spawning blocking doors from blob...") + _spawn_blocking_doors() + print("GameWorld: Client - Blocking doors spawned") + + print("GameWorld: Client - Spawning room triggers from blob...") + _spawn_room_triggers() + print("GameWorld: Client - Room triggers spawned") + + # Update spawn points + print("GameWorld: Client - Updating spawn points...") + var host_room = dungeon_sync_metadata.host_room + if not host_room.is_empty(): + print("GameWorld: Client - Using host's room for spawn points: ", host_room) + _update_spawn_points(host_room) + _move_players_to_host_room(host_room) + else: + print("GameWorld: Client - Host room not available, using start room") + _update_spawn_points() + + # Move all players to start room + print("GameWorld: Client - Moving all players to start room...") + _move_all_players_to_start_room() + print("GameWorld: Client - Players moved to start room") + + # Update camera + await get_tree().process_frame + print("GameWorld: Client - Updating camera...") + _update_camera() + print("GameWorld: Client - Camera updated") + + # Load HUD + print("=== GameWorld: Client - About to load HUD (call_deferred) ===") + call_deferred("_load_hud") + get_tree().create_timer(0.2).timeout.connect(func(): + var hud_check = get_node_or_null("IngameHUD") + if not hud_check or not is_instance_valid(hud_check): + print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===") + _load_hud() + else: + print("GameWorld: Client - HUD already loaded (found in scene tree)") + ) + + dungeon_sync_complete = true + print("=== GameWorld: Client - Dungeon blob sync completed ===") + + call_deferred("_fix_player_appearance_after_dungeon_sync") + +func _check_and_render_dungeon(): + # Assemble dungeon_data from chunks and render + if dungeon_sync_complete: + return # Already rendered + + if dungeon_sync_metadata.is_empty(): + print("GameWorld: Client - Cannot render: metadata not received") + return + + if dungeon_sync_chunks.size() < dungeon_sync_total_chunks: + print("GameWorld: Client - Cannot render: missing chunks (", dungeon_sync_chunks.size(), "/", dungeon_sync_total_chunks, ")") + return + + if dungeon_sync_rooms.is_empty(): + print("GameWorld: Client - Cannot render: rooms not received") + return + + print("GameWorld: Client - All chunks received! Assembling dungeon data...") + + # Assemble tile_grid and grid from chunks + var map_size = dungeon_sync_metadata.map_size + var tile_grid = [] + var grid = [] + + # Initialize arrays + for x in range(map_size.x): + tile_grid.append([]) + grid.append([]) + for y in range(map_size.y): + tile_grid[x].append(Vector2i(0, 0)) + grid[x].append(0) + + # Fill from chunks + # Each chunk contains columns (x) with rows (y) from start_row to end_row + # tile_grid_chunk[x] contains tile_grid[x][start_row..end_row] + for chunk_idx in range(dungeon_sync_total_chunks): + if not dungeon_sync_chunks.has(chunk_idx): + print("GameWorld: Client - ERROR: Missing chunk ", chunk_idx) + return + + var chunk = dungeon_sync_chunks[chunk_idx] + var start_row = chunk.start_row + var end_row = chunk.end_row + var tile_grid_chunk = chunk.tile_grid + var grid_chunk = chunk.grid + + # For each column x, copy the row range from the chunk + for col_x in range(min(tile_grid_chunk.size(), map_size.x)): + if col_x >= tile_grid.size(): + continue + var tile_col_chunk = tile_grid_chunk[col_x] + var grid_col_chunk = grid_chunk[col_x] + for row_idx in range(tile_col_chunk.size()): + var actual_y = start_row + row_idx + if actual_y >= map_size.y: + break + if row_idx < tile_col_chunk.size(): + tile_grid[col_x][actual_y] = tile_col_chunk[row_idx] + grid[col_x][actual_y] = grid_col_chunk[row_idx] + + # Reconstruct dungeon_data + dungeon_data = { + "tile_grid": tile_grid, + "grid": grid, + "map_size": map_size, + "start_room": dungeon_sync_rooms.start_room, + "rooms": dungeon_sync_rooms.rooms, + "enemies": [], + "torches": [], + "interactable_objects": [] + } + + dungeon_seed = dungeon_sync_metadata.seed + current_level = dungeon_sync_metadata.level + + print("GameWorld: Client - Dungeon data assembled! Rendering...") + LogManager.log("GameWorld: Client assembled dungeon from chunks for level " + str(current_level), LogManager.CATEGORY_DUNGEON) + + # Fix player appearance + _fix_player_appearance_after_dungeon_sync() + + # Render dungeon + print("GameWorld: Client - Rendering dungeon from chunks...") + _render_dungeon() + print("GameWorld: Client - Dungeon rendered") + + # Update spawn points + print("GameWorld: Client - Updating spawn points...") + var host_room = dungeon_sync_metadata.host_room + if not host_room.is_empty(): + print("GameWorld: Client - Using host's room for spawn points: ", host_room) + _update_spawn_points(host_room) + _move_players_to_host_room(host_room) + else: + print("GameWorld: Client - Host room not available, using start room") + _update_spawn_points() + + # Move all players to start room + print("GameWorld: Client - Moving all players to start room...") + _move_all_players_to_start_room() + print("GameWorld: Client - Players moved to start room") + + # Update camera + await get_tree().process_frame + print("GameWorld: Client - Updating camera...") + _update_camera() + print("GameWorld: Client - Camera updated") + + # Load HUD + print("=== GameWorld: Client - About to load HUD (call_deferred) ===") + call_deferred("_load_hud") + get_tree().create_timer(0.2).timeout.connect(func(): + var hud_check = get_node_or_null("IngameHUD") + if not hud_check or not is_instance_valid(hud_check): + print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===") + _load_hud() + else: + print("GameWorld: Client - HUD already loaded (found in scene tree)") + ) + + dungeon_sync_complete = true + print("=== GameWorld: Client - Chunked dungeon sync completed ===") + + call_deferred("_fix_player_appearance_after_dungeon_sync") + +@rpc("authority", "reliable", "call_local") +func _sync_dungeon_entities(non_essential_data: Dictionary): + # Clients receive non-essential dungeon data (enemies, torches, interactable_objects) + # This is sent separately to avoid exceeding WebRTC message size limits + print("=== _sync_dungeon_entities RPC RECEIVED on client ===") + print("=== [CHUNK FINAL] Client received entities data ===") + if not multiplayer.is_server(): + # Send acknowledgment for entities (chunk total_chunks + 1) + _ack_dungeon_chunk.rpc_id(1, dungeon_sync_total_chunks + 1) + # Merge non-essential data into dungeon_data + if dungeon_data.is_empty(): + print("GameWorld: Client - WARNING: Received entities sync but dungeon_data is empty!") + return + + dungeon_data["enemies"] = non_essential_data.get("enemies", []) + dungeon_data["torches"] = non_essential_data.get("torches", []) + dungeon_data["interactable_objects"] = non_essential_data.get("interactable_objects", []) + + print("GameWorld: Client - Received entities sync - enemies: ", dungeon_data.enemies.size(), ", torches: ", dungeon_data.torches.size(), ", objects: ", dungeon_data.interactable_objects.size()) + + # Spawn entities (torches and enemies are already synced separately, but ensure interactable objects are spawned) + if not dungeon_data.interactable_objects.is_empty(): + print("GameWorld: Client - Spawning interactable objects from entities sync...") + _spawn_interactable_objects() + print("GameWorld: Client - Interactable objects spawned") + + # Spawn blocking doors and room triggers if not already spawned + _spawn_blocking_doors() + _spawn_room_triggers() + +func _fix_player_appearance_after_dungeon_sync(): + # Re-randomize appearance for all players that were spawned before dungeon_seed was received + # This ensures all players (including joiners) have the same appearance across all clients + # IMPORTANT: Only run on clients, not on server (server players already have correct appearance) + if multiplayer.is_server(): + return # Server players already have correct appearance, skip + + if dungeon_seed == 0: + return # No seed yet, skip + + var players = get_tree().get_nodes_in_group("player") + for player in players: + if player and is_instance_valid(player) and player.has_method("_reinitialize_appearance_with_seed"): + # Only re-initialize players that were marked as needing appearance reset + # This prevents re-randomizing players that already have correct appearance + if player.has_meta("needs_appearance_reset"): + player._reinitialize_appearance_with_seed(dungeon_seed) + LogManager.log("GameWorld: Re-initialized appearance for player " + str(player.name) + " with dungeon_seed " + str(dungeon_seed), LogManager.CATEGORY_GAMEPLAY) func _spawn_torches(): # Spawn torches from dungeon data @@ -1764,6 +3633,12 @@ func _spawn_interactable_objects(): push_error("ERROR: Object data missing 'setup_function' field: ", object_data) continue + # Check if object with this index already exists (prevent duplicates) + var existing_obj = entities_node.get_node_or_null("InteractableObject_%d" % i) + if existing_obj and is_instance_valid(existing_obj): + LogManager.log("GameWorld: Object InteractableObject_%d already exists, skipping spawn" % i, LogManager.CATEGORY_DUNGEON) + continue + var obj = interactable_object_scene.instantiate() # Use consistent naming: InteractableObject_ obj.name = "InteractableObject_%d" % i @@ -1793,12 +3668,13 @@ func _spawn_interactable_objects(): # Add to group for easy access obj.add_to_group("interactable_object") - + # Apply any pending chest open sync that arrived before this chest spawned if obj.has_method("setup_chest") and pending_chest_opens.has(obj.name): var chest_state = pending_chest_opens[obj.name] if obj.has_method("_sync_chest_open"): - obj._sync_chest_open(chest_state.loot_type, chest_state.player_peer_id) + var item_data = chest_state.get("item_data", {}) + obj._sync_chest_open(chest_state.loot_type, chest_state.player_peer_id, item_data) pending_chest_opens.erase(obj.name) # Apply pending state sync if it arrived before this object spawned @@ -1938,8 +3814,23 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): LogManager.log("GameWorld: Client spawned dungeon enemy via RPC: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + ")", LogManager.CATEGORY_DUNGEON) -func _sync_existing_chest_states_to_client(client_peer_id: int): - # Sync chest open states to new client +func _sync_existing_chest_states_to_client(client_peer_id: int, retry_count: int = 0): + # Sync chest open states to new client with retry logic + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + return + + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + if retry_count < 15: # Reduced from 30 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_existing_chest_states_to_client(client_peer_id, retry_count + 1) + ) + return + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -1957,8 +3848,8 @@ func _sync_existing_chest_states_to_client(client_peer_id: int): print("GameWorld: Synced ", opened_chest_count, " opened chests to client ", client_peer_id) -func _sync_broken_objects_to_client(client_peer_id: int): - # Sync broken interactable objects to new client +func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0): + # Sync broken interactable objects to new client with retry logic # Check if node is still valid and in tree if not is_inside_tree(): LogManager.log("GameWorld: Node not in tree, ignoring broken objects sync RPC", LogManager.CATEGORY_NETWORK) @@ -1967,6 +3858,15 @@ func _sync_broken_objects_to_client(client_peer_id: int): if not multiplayer.is_server(): return + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + if retry_count < 15: # Reduced from 30 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_broken_objects_to_client(client_peer_id, retry_count + 1) + ) + return + if broken_objects.is_empty(): LogManager.log("GameWorld: No broken objects to sync to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) return @@ -2022,8 +3922,8 @@ func _sync_chest_state(obj_name: String, is_opened: bool): chest.sprite.frame = chest.chest_opened_frame print("GameWorld: Client received chest state sync for ", obj_name, " - opened: ", is_opened) -func _sync_existing_torches_to_client(client_peer_id: int): - # Sync existing torches to newly connected client +func _sync_existing_torches_to_client(client_peer_id: int, retry_count: int = 0): + # Sync existing torches to newly connected client with retry logic # Check if node is still valid and in tree if not is_inside_tree(): LogManager.log("GameWorld: Node not in tree, ignoring torch sync RPC", LogManager.CATEGORY_NETWORK) @@ -2032,6 +3932,31 @@ func _sync_existing_torches_to_client(client_peer_id: int): if not multiplayer.is_server(): return + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + if retry_count < 15: # Reduced from 30 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_existing_torches_to_client(client_peer_id, retry_count + 1) + ) + return + else: + # Max retries reached - give up, don't try RPC (will fail anyway) + LogManager.log("GameWorld: Max retries reached for torch sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK) + return + + # CRITICAL: Double-check that peer is in get_peers() before calling RPC + # This is required because Godot's rpc_id() will fail if peer isn't in get_peers() + var peers = multiplayer.get_peers() + if client_peer_id not in peers: + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before torch sync RPC, retrying...", LogManager.CATEGORY_NETWORK) + if retry_count < 15: # Reduced from 30 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_existing_torches_to_client(client_peer_id, retry_count + 1) + ) + return + if dungeon_data.is_empty() or not dungeon_data.has("torches"): return @@ -2058,11 +3983,38 @@ func _sync_torch_spawn(torch_position: Vector2, torch_rotation: float): torch.rotation_degrees = torch_rotation print("Client spawned torch at ", torch_position, " with rotation ", torch_rotation) -func _sync_existing_door_states_to_client(client_peer_id: int): - # Sync door states to new client +func _sync_existing_door_states_to_client(client_peer_id: int, retry_count: int = 0): + # Sync door states to new client with retry logic + if not is_inside_tree(): + return + if not multiplayer.is_server(): return + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + if retry_count < 20: # Reduced from 60 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_existing_door_states_to_client(client_peer_id, retry_count + 1) + ) + return + else: + # Max retries reached - give up, don't try RPC (will fail anyway) + LogManager.log("GameWorld: Max retries reached for door state sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK) + return + + # CRITICAL: Double-check that peer is in get_peers() before calling RPC + var peers = multiplayer.get_peers() + if client_peer_id not in peers: + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before door sync RPC, retrying...", LogManager.CATEGORY_NETWORK) + if retry_count < 20: # Reduced from 60 + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_existing_door_states_to_client(client_peer_id, retry_count + 1) + ) + return + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -2171,7 +4123,12 @@ func _sync_existing_loot_to_client(client_peer_id: int): var loot_count = 0 for child in entities_node.get_children(): if child.is_in_group("loot"): - # Get loot data + # Get loot data including current velocity and position_z for accurate sync + var current_velocity = child.velocity if "velocity" in child else Vector2.ZERO + var current_velocity_z = child.velocity_z if "velocity_z" in child else 0.0 + var _current_position_z = child.position_z if "position_z" in child else 0.0 + var _is_airborne = child.is_airborne if "is_airborne" in child else false + var loot_data = { "position": child.global_position, "loot_type": child.loot_type if "loot_type" in child else 0, @@ -2183,16 +4140,18 @@ func _sync_existing_loot_to_client(client_peer_id: int): var item = child.item # Use Item.save() method to get all item data as dictionary var item_data = item.save() - _sync_item_loot_spawn.rpc_id(client_peer_id, loot_data.position, item_data, Vector2.ZERO, 0.0, loot_data.loot_id) + # Sync with current velocity/position_z if still bouncing, otherwise use zero + _sync_item_loot_spawn.rpc_id(client_peer_id, loot_data.position, item_data, current_velocity, current_velocity_z, loot_data.loot_id) else: - _sync_loot_spawn.rpc_id(client_peer_id, loot_data.position, loot_data.loot_type, Vector2.ZERO, 0.0, loot_data.loot_id) + # Sync with current velocity/position_z if still bouncing, otherwise use zero + _sync_loot_spawn.rpc_id(client_peer_id, loot_data.position, loot_data.loot_type, current_velocity, current_velocity_z, loot_data.loot_id) loot_count += 1 LogManager.log("GameWorld: Synced " + str(loot_count) + " loot items to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) -func _sync_activated_switches_to_client(client_peer_id: int): - # Sync activated floor switches to new client +func _sync_activated_switches_to_client(client_peer_id: int, retry_count: int = 0): + # Sync activated floor switches to new client with retry logic # Check if node is still valid and in tree if not is_inside_tree(): LogManager.log("GameWorld: Node not in tree, ignoring switch sync RPC", LogManager.CATEGORY_NETWORK) @@ -2201,6 +4160,31 @@ func _sync_activated_switches_to_client(client_peer_id: int): if not multiplayer.is_server(): return + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + var max_retries = 20 # Reduced from 30/60 + if retry_count < max_retries: + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_activated_switches_to_client(client_peer_id, retry_count + 1) + ) + return + else: + LogManager.log("GameWorld: Max retries reached for switch sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK) + return + + # CRITICAL: Double-check that peer is in get_peers() before calling RPC + var peers = multiplayer.get_peers() + if client_peer_id not in peers: + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before switch sync RPC, retrying...", LogManager.CATEGORY_NETWORK) + var max_retries = 20 # Reduced from 30/60 + if retry_count < max_retries: + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_activated_switches_to_client(client_peer_id, retry_count + 1) + ) + return + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -2228,8 +4212,8 @@ func _sync_switch_state(switch_name: String, is_activated: bool): switch_node._update_visual() LogManager.log("GameWorld: Client received switch state sync for " + switch_name + " - activated: " + str(is_activated), LogManager.CATEGORY_NETWORK) -func _sync_interactable_object_positions_to_client(client_peer_id: int): - # Sync current positions of interactable objects (boxes that were moved/thrown) +func _sync_interactable_object_positions_to_client(client_peer_id: int, retry_count: int = 0): + # Sync current positions of interactable objects (boxes that were moved/thrown) with retry logic # Check if node is still valid and in tree if not is_inside_tree(): LogManager.log("GameWorld: Node not in tree, ignoring interactable object position sync RPC", LogManager.CATEGORY_NETWORK) @@ -2238,6 +4222,35 @@ func _sync_interactable_object_positions_to_client(client_peer_id: int): if not multiplayer.is_server(): return + # Check if peer is recognized before sending RPC + if not _check_peer_recognized(client_peer_id): + # Reduced retries - don't retry so many times for host + var max_retries = 20 # Reduced from 30/60 + if retry_count < max_retries: + if retry_count % 10 == 0: # Log every 10th retry to reduce spam + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not ready for interactable object sync, retrying (" + str(retry_count + 1) + "/" + str(max_retries) + ")...", LogManager.CATEGORY_NETWORK) + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_interactable_object_positions_to_client(client_peer_id, retry_count + 1) + ) + return + else: + # After max retries, give up - don't try to send RPC (will fail anyway on web) + LogManager.log("GameWorld: Max retries reached for interactable object position sync for peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK) + return + + # CRITICAL: Double-check that peer is in get_peers() before calling RPC + var peers = multiplayer.get_peers() + if client_peer_id not in peers: + LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before interactable object sync RPC, retrying...", LogManager.CATEGORY_NETWORK) + var max_retries = 20 # Reduced from 30/60 + if retry_count < max_retries: + get_tree().create_timer(0.2).timeout.connect(func(): + if is_inside_tree() and multiplayer.is_server(): + _sync_interactable_object_positions_to_client(client_peer_id, retry_count + 1) + ) + return + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -2397,7 +4410,7 @@ func _apply_pending_door_state(door: Node): pending_door_states.erase(door.name) @rpc("authority", "reliable") -func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_peer_id: int): +func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_peer_id: int, item_data: Dictionary = {}): # Client receives chest open sync by name (avoids node path RPC errors) if not is_inside_tree(): return @@ -2420,12 +4433,13 @@ func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_ break if chest and chest.has_method("_sync_chest_open"): - chest._sync_chest_open(loot_type_str, player_peer_id) + chest._sync_chest_open(loot_type_str, player_peer_id, item_data) else: # Store for later if chest isn't spawned yet pending_chest_opens[chest_name] = { "loot_type": loot_type_str, - "player_peer_id": player_peer_id + "player_peer_id": player_peer_id, + "item_data": item_data } func _apply_interactable_state(obj: Node, state: Dictionary): @@ -2506,6 +4520,13 @@ func _clear_level(): # Clear dungeon data (but keep it for now until new one is generated) # dungeon_data = {} # Don't clear yet, wait for new generation + # Clear fog of war + seen_by_player.clear() + combined_seen = PackedInt32Array() + if fog_node and is_instance_valid(fog_node): + fog_node.queue_free() + fog_node = null + LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON) func _hide_all_players(): @@ -2611,6 +4632,20 @@ func _position_new_joiner_and_sync_positions(new_peer_id: int): # Use GameWorld RPC to avoid node path resolution issues _sync_player_position_by_name.rpc_id(new_peer_id, player.name, player.global_position) LogManager.log("GameWorld: Sent player " + player.name + " position (" + str(player.global_position) + ") to new joiner " + str(new_peer_id), LogManager.CATEGORY_GAMEPLAY) + + # Sync equipment for existing players to new joiner + if "character_stats" in player and player.character_stats: + var equipment_data = {} + for slot_name in player.character_stats.equipment.keys(): + var item = player.character_stats.equipment[slot_name] + if item: + equipment_data[slot_name] = item.save() # Serialize item data + else: + equipment_data[slot_name] = null + # Send equipment sync via player's RPC (the player node will route it correctly) + if player.has_method("_sync_equipment"): + player._sync_equipment.rpc_id(new_peer_id, equipment_data) + 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) var existing_peers = multiplayer.get_peers() @@ -2658,6 +4693,14 @@ func _move_all_players_to_start_room(): 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) @@ -2671,6 +4714,12 @@ func _move_all_players_to_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) @@ -2855,7 +4904,12 @@ func _on_player_reached_stairs(player: Node): else: LogManager.log("GameWorld: ERROR: Server dungeon_data has NO 'enemies' key when syncing new level!", LogManager.CATEGORY_DUNGEON) - _rpc_to_ready_peers("_sync_dungeon", [dungeon_data, dungeon_seed, current_level, start_room]) + # Use blob sync for all connected peers + var peers = multiplayer.get_peers() + for peer_id in peers: + if peer_id != 1: # Don't sync to self (server is peer 1) + print("GameWorld: HOST - Syncing new level (from stairs) to peer ", peer_id, " using blob sync") + _sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, start_room) func _get_local_player_stats() -> Dictionary: # Get stats for the local player (for level complete screen) @@ -3098,6 +5152,7 @@ func _load_hud(): var existing_hud = get_node_or_null("IngameHUD") if existing_hud and is_instance_valid(existing_hud): LogManager.log("GameWorld: HUD already exists, skipping load (will just reset timer)", LogManager.CATEGORY_DUNGEON) + print("GameWorld: HUD already exists, skipping load") # Reset timer for new level if method exists if existing_hud.has_method("reset_level_timer"): existing_hud.reset_level_timer() @@ -3108,27 +5163,34 @@ func _load_hud(): # Use a try-catch-like approach by checking for errors var hud_scene_path = "res://scenes/ingame_hud.tscn" + print("GameWorld: Loading HUD from ", hud_scene_path) + # Check if scene exists if not ResourceLoader.exists(hud_scene_path): LogManager.log("GameWorld: HUD scene not found at " + str(hud_scene_path) + " - HUD disabled", LogManager.CATEGORY_DUNGEON) + print("GameWorld: ERROR - HUD scene not found at ", hud_scene_path) return # Try to load the scene var hud_scene = load(hud_scene_path) if not hud_scene: - print("GameWorld: Warning - Failed to load HUD scene from ", hud_scene_path) + print("GameWorld: ERROR - Failed to load HUD scene from ", hud_scene_path) + LogManager.log("GameWorld: Warning - Failed to load HUD scene from " + str(hud_scene_path), LogManager.CATEGORY_DUNGEON) return # Try to instantiate var hud = null if hud_scene.has_method("instantiate"): hud = hud_scene.instantiate() + print("GameWorld: HUD scene instantiated") else: LogManager.log("GameWorld: Warning - HUD scene is not a PackedScene", LogManager.CATEGORY_DUNGEON) + print("GameWorld: ERROR - HUD scene is not a PackedScene") return if not hud: LogManager.log("GameWorld: Warning - Failed to instantiate HUD scene", LogManager.CATEGORY_DUNGEON) + print("GameWorld: ERROR - Failed to instantiate HUD scene") return # Add to scene tree @@ -3138,6 +5200,7 @@ func _load_hud(): hud.visible = true hud.layer = 100 # High layer to ensure HUD is on top of everything + print("GameWorld: Adding HUD to scene tree (visible: ", hud.visible, ", layer: ", hud.layer, ")") add_child(hud) # Reset timer if method exists @@ -3145,7 +5208,9 @@ func _load_hud(): hud.reset_level_timer() LogManager.log("GameWorld: HUD loaded successfully and added to scene tree", LogManager.CATEGORY_DUNGEON) + print("GameWorld: HUD loaded successfully and added to scene tree") print("GameWorld: HUD visible: ", hud.visible, ", layer: ", hud.layer) + print("GameWorld: HUD is_inside_tree: ", hud.is_inside_tree()) func _initialize_hud(): # Find or get the HUD and reset its level timer @@ -3732,7 +5797,7 @@ func _spawn_blocking_doors(): LogManager.log("GameWorld: Found existing pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON) else: push_warning("GameWorld: WARNING - Pillar switch found but no pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") - pillar should have been placed during dungeon generation!") - rooms_with_pillars[room_key] = true + rooms_with_pillars[room_key] = true else: push_error("GameWorld: ERROR - Attempted to connect door ", door.name, " to switch ", existing_switch.name, " but rooms don't match! Door room: (", door_blocking_room.get("x", "?"), ",", door_blocking_room.get("y", "?"), "), Switch room: (", existing_switch_room_final.get("x", "?") if existing_switch_room_final else "?", ",", existing_switch_room_final.get("y", "?") if existing_switch_room_final else "?", ")") # Don't connect - spawn a new switch instead diff --git a/src/scripts/ingame_hud.gd b/src/scripts/ingame_hud.gd index 6a7a7ac..4b9c994 100644 --- a/src/scripts/ingame_hud.gd +++ b/src/scripts/ingame_hud.gd @@ -16,6 +16,9 @@ var label_host: Label = null var label_player_count: Label = null var label_room_code: Label = null var label_disconnected: Label = null +var label_matchbox_status: Label = null +var label_ice_status: Label = null +var label_data_channels_status: Label = null var game_world: Node = null var network_manager: Node = null @@ -44,6 +47,17 @@ func _ready(): label_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount") label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode") label_disconnected = get_node_or_null("CenterTop/LabelDisconnected") + label_matchbox_status = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerConnectionStatus/LabelMatchboxStatus") + label_ice_status = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerConnectionStatus/LabelICEStatus") + label_data_channels_status = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerConnectionStatus/LabelDataChannelsStatus") + + # Debug: Log if connection status labels weren't found + if not label_matchbox_status: + print("IngameHUD: WARNING - label_matchbox_status not found!") + if not label_ice_status: + print("IngameHUD: WARNING - label_ice_status not found!") + if not label_data_channels_status: + print("IngameHUD: WARNING - label_data_channels_status not found!") # Find network manager network_manager = get_node_or_null("/root/NetworkManager") @@ -78,6 +92,11 @@ func _ready(): # Update host info display _update_host_info() + # Initially hide connection status (will be shown if WebRTC mode) + var connection_status_container = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerConnectionStatus") + if connection_status_container: + connection_status_container.visible = false + # Start level timer level_start_time = Time.get_ticks_msec() / 1000.0 @@ -188,6 +207,9 @@ func _process(_delta): # Update boss health (if boss exists) _update_boss_health() + + # Update connection status (only for WebRTC) + _update_connection_status() func _update_hud_scale(): # Scale HUD to an integer factor to keep pixel text crisp @@ -299,3 +321,120 @@ func _update_boss_health(): func reset_level_timer(): # Reset timer when starting a new level start_timer() + +func _update_connection_status(): + # Only show connection status for WebRTC mode + var connection_status_container = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerConnectionStatus") + if not network_manager: + if connection_status_container: + connection_status_container.visible = false + return + + if network_manager.network_mode != 1: # Not WebRTC + if connection_status_container: + connection_status_container.visible = false + return + + # Show connection status container and labels (for both host and joiner) + if connection_status_container: + connection_status_container.visible = true + else: + # Debug: Log if container not found + if not has_meta("connection_status_container_warned"): + set_meta("connection_status_container_warned", true) + print("IngameHUD: WARNING - connection_status_container not found!") + + if label_matchbox_status: + label_matchbox_status.visible = true + else: + if not has_meta("label_matchbox_status_warned"): + set_meta("label_matchbox_status_warned", true) + print("IngameHUD: WARNING - label_matchbox_status not found!") + + if label_ice_status: + label_ice_status.visible = true + else: + if not has_meta("label_ice_status_warned"): + set_meta("label_ice_status_warned", true) + print("IngameHUD: WARNING - label_ice_status not found!") + + if label_data_channels_status: + label_data_channels_status.visible = true + else: + if not has_meta("label_data_channels_status_warned"): + set_meta("label_data_channels_status_warned", true) + print("IngameHUD: WARNING - label_data_channels_status not found!") + + # Update Matchbox connection status + if network_manager.matchbox_client and label_matchbox_status: + var is_net_connected = network_manager.matchbox_client.is_matchbox_connected() + if is_net_connected: + label_matchbox_status.text = "Matchbox: Connected" + label_matchbox_status.modulate = Color.GREEN + else: + label_matchbox_status.text = "Matchbox: Disconnected" + label_matchbox_status.modulate = Color.RED + + # Update ICE connection status + if network_manager.matchbox_client and label_ice_status: + var ice_status = network_manager.matchbox_client.get_ice_connection_status() + if ice_status.is_empty(): + label_ice_status.text = "ICE: No peers" + label_ice_status.modulate = Color.YELLOW + else: + # Check if any peer has ICE connected + # For WebRTC, we consider it "connected" if: + # 1. Connection state is CONNECTED (2), OR + # 2. Signaling is STABLE (0) - offer/answer exchange complete (even if ICE still finalizing) + # This is more lenient because WebRTC in browsers can have different timing than native builds + var any_connected = false + var any_establishing = false + for peer_id in ice_status.keys(): + var peer_status = ice_status[peer_id] + var conn_state = peer_status.get("connection_state", -1) + var sig_state = peer_status.get("signaling_state", -1) + + # Connection is fully connected + if conn_state == 2: # CONNECTED + any_connected = true + break + # Signaling is STABLE (0) - offer/answer exchange is complete + # Even if ICE is still CONNECTING, the connection is essentially established + # This matches native behavior where everything works fine + elif sig_state == 0: # STABLE signaling means connection is ready + any_establishing = true + + if any_connected or any_establishing: + label_ice_status.text = "ICE: Connected" + label_ice_status.modulate = Color.GREEN + else: + # Check connection state and ICE candidate exchange + var connecting = false + var candidate_info = "" + for peer_id in ice_status.keys(): + var conn_state = ice_status[peer_id].get("connection_state", -1) + if conn_state == 1: # CONNECTING + connecting = true + # Show ICE candidate counts to help debug + var sent = ice_status[peer_id].get("candidates_sent", 0) + var received = ice_status[peer_id].get("candidates_received", 0) + if sent > 0 or received > 0: + candidate_info = " (sent: " + str(sent) + ", recv: " + str(received) + ")" + break + + if connecting: + label_ice_status.text = "ICE: Connecting..." + candidate_info + label_ice_status.modulate = Color.YELLOW + else: + label_ice_status.text = "ICE: Disconnected" + label_ice_status.modulate = Color.RED + + # Update data channels status + if network_manager.matchbox_client and label_data_channels_status: + var channels_connected = network_manager.matchbox_client.are_data_channels_connected() + if channels_connected: + label_data_channels_status.text = "Data: Connected" + label_data_channels_status.modulate = Color.GREEN + else: + label_data_channels_status.text = "Data: Disconnected" + label_data_channels_status.modulate = Color.RED diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 1b7e981..1ba094c 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -499,7 +499,19 @@ func setup_pot(): var pot_frames = [1, 2, 3, 20, 21, 22, 58] if sprite: - sprite.frame = pot_frames[randi() % pot_frames.size()] + var box_seed = 0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world: + box_seed = game_world.dungeon_seed + # Add position and object_index to seed to make each box unique but deterministic + box_seed += int(global_position.x) * 1000 + int(global_position.y) + if has_meta("object_index"): + box_seed += get_meta("object_index") * 10000 + + var rng = RandomNumberGenerator.new() + rng.seed = box_seed + var index = rng.randi() % pot_frames.size() + sprite.frame = pot_frames[index] func setup_liftable_barrel(): object_type = "LiftableBarrel" @@ -511,7 +523,19 @@ func setup_liftable_barrel(): var barrel_frames = [4, 23] if sprite: - sprite.frame = barrel_frames[randi() % barrel_frames.size()] + var box_seed = 0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world: + box_seed = game_world.dungeon_seed + # Add position and object_index to seed to make each box unique but deterministic + box_seed += int(global_position.x) * 1000 + int(global_position.y) + if has_meta("object_index"): + box_seed += get_meta("object_index") * 10000 + + var rng = RandomNumberGenerator.new() + rng.seed = box_seed + var index = rng.randi() % barrel_frames.size() + sprite.frame = barrel_frames[index] func setup_pushable_barrel(): object_type = "PushableBarrel" @@ -607,9 +631,9 @@ func setup_pushable_high_box(): # Use deterministic randomness based on dungeon seed and position # This ensures host and clients get the same chest variant var highbox_seed = 0 - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and "dungeon_seed" in game_world: - highbox_seed = game_world.dungeon_seed + var world = get_tree().get_first_node_in_group("game_world") + if world and "dungeon_seed" in world: + highbox_seed = world.dungeon_seed # Add position to seed to make each chest unique but deterministic highbox_seed += int(global_position.x) * 1000 + int(global_position.y) @@ -634,77 +658,103 @@ func _open_chest(by_player: Node = null): # Track opened chest for syncing to new clients if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and has_meta("object_index"): + var world = get_tree().get_first_node_in_group("game_world") + if world and has_meta("object_index"): var obj_index = get_meta("object_index") - game_world.opened_chests[obj_index] = true + world.opened_chests[obj_index] = true LogManager.log("Chest: Tracked opened chest with index " + str(obj_index), LogManager.CATEGORY_NETWORK) if sprite and chest_opened_frame >= 0: sprite.frame = chest_opened_frame - # Random loot type - var loot_types = [ - {"type": "coin", "name": "Coin", "color": Color(1.0, 0.84, 0.0)}, - {"type": "apple", "name": "Apple", "color": Color.GREEN}, - {"type": "banana", "name": "Banana", "color": Color.YELLOW}, - {"type": "cherry", "name": "Cherry", "color": Color.RED}, - {"type": "key", "name": "Key", "color": Color.YELLOW} - ] - var selected_loot = loot_types[randi() % loot_types.size()] + # Get random item from entire item database (using chest rarity weights) + # Use deterministic randomness based on dungeon seed and chest position + var chest_seed = 0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world: + chest_seed = game_world.dungeon_seed + # Add position to seed to make each chest unique but deterministic + chest_seed += int(global_position.x) * 1000 + int(global_position.y) + + # Create deterministic RNG for this chest + var chest_rng = RandomNumberGenerator.new() + chest_rng.seed = chest_seed + + # Get random item using deterministic RNG + # We need to manually select by rarity since get_random_chest_item() uses global randi() + var rarity_roll = chest_rng.randf() + var rarity: ItemDatabase.ItemRarity + if rarity_roll < 0.4: + rarity = ItemDatabase.ItemRarity.COMMON + elif rarity_roll < 0.75: + rarity = ItemDatabase.ItemRarity.UNCOMMON + elif rarity_roll < 0.95: + rarity = ItemDatabase.ItemRarity.RARE + else: + rarity = ItemDatabase.ItemRarity.EPIC if chest_rng.randf() < 0.5 else ItemDatabase.ItemRarity.CONSUMABLE + + # Get candidates for this rarity using deterministic RNG + ItemDatabase._initialize() + var candidates = [] + # Access static item_definitions directly + for item_id in ItemDatabase.item_definitions.keys(): + var item_data = ItemDatabase.item_definitions[item_id] + if item_data.has("rarity") and item_data["rarity"] == rarity: + candidates.append(item_id) + + # Fallback to common if no candidates + if candidates.is_empty(): + for item_id in ItemDatabase.item_definitions.keys(): + var item_data = ItemDatabase.item_definitions[item_id] + if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON: + candidates.append(item_id) + + # Select random item from candidates using deterministic RNG + var random_item_id = candidates[chest_rng.randi() % candidates.size()] if not candidates.is_empty() else null + var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null # CRITICAL: Instantly give item to player instead of spawning loot object - if by_player and is_instance_valid(by_player) and by_player.is_in_group("player"): - # Give item directly to player based on type - match selected_loot.type: - "coin": - if by_player.has_method("add_coins"): - by_player.add_coins(1) - # Show pickup notification with coin graphic - var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") - _show_item_pickup_notification(by_player, "+1 COIN", selected_loot.color, coin_texture, 6, 1, 0) - "apple": - var heal_amount = 20.0 - if by_player.has_method("heal"): - by_player.heal(heal_amount) - # Show pickup notification with apple graphic - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(by_player, "+" + str(int(heal_amount)) + " HP", selected_loot.color, items_texture, 20, 14, (8 * 20) + 10) - "banana": - var heal_amount = 20.0 - if by_player.has_method("heal"): - by_player.heal(heal_amount) - # Show pickup notification with banana graphic - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(by_player, "+" + str(int(heal_amount)) + " HP", selected_loot.color, items_texture, 20, 14, (8 * 20) + 11) - "cherry": - var heal_amount = 20.0 - if by_player.has_method("heal"): - by_player.heal(heal_amount) - # Show pickup notification with cherry graphic - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(by_player, "+" + str(int(heal_amount)) + " HP", selected_loot.color, items_texture, 20, 14, (8 * 20) + 12) - "key": - if by_player.has_method("add_key"): - by_player.add_key(1) - # Show pickup notification with key graphic - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(by_player, "+1 KEY", selected_loot.color, items_texture, 20, 14, (13 * 20) + 10) + if by_player and is_instance_valid(by_player) and by_player.is_in_group("player") and chest_item: + # Add item to player inventory + if by_player.character_stats: + by_player.character_stats.add_item(chest_item) + + # Show pickup notification + var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null + var display_text = chest_item.item_name.to_upper() + var item_color = Color.WHITE + + # Determine color based on item type/rarity + if chest_item.item_type == Item.ItemType.Restoration: + item_color = Color.GREEN + elif chest_item.item_type == Item.ItemType.Equippable: + item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) + else: + item_color = Color.WHITE + + # Show notification with item sprite + if items_texture: + _show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame) + else: + # Fallback: just show text + _show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0) # Play chest open sound if has_node("SfxChestOpen"): $SfxChestOpen.play() - print(name, " opened by ", by_player.name, "! Item given: ", selected_loot.name) + print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name) # Sync chest opening visual to all clients (item already given on server) if multiplayer.has_multiplayer_peer(): var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 - var game_world = get_tree().get_first_node_in_group("game_world") + # Reuse game_world from earlier in the function if game_world and game_world.has_method("_rpc_to_ready_peers"): var chest_name = name if has_meta("object_index"): chest_name = "InteractableObject_%d" % get_meta("object_index") - game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, selected_loot.type if by_player else "coin", player_peer_id]) + # Sync chest open visual with item_data so clients can show the floating text + var item_data = chest_item.save() if chest_item else {} + game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data]) else: push_error("Chest: ERROR - No valid player to give item to!") @@ -737,7 +787,7 @@ func _request_chest_open(player_peer_id: int): _open_chest(player) @rpc("any_peer", "reliable") -func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0): +func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0, item_data: Dictionary = {}): # Sync chest opening to all clients (only visual - item already given on server) if not is_chest_opened and sprite and chest_opened_frame >= 0: is_chest_opened = true @@ -757,26 +807,43 @@ func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0): break if player and is_instance_valid(player): - # Show notification based on loot type (same as server) - match loot_type_str: - "coin": - var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") - _show_item_pickup_notification(player, "+1 COIN", Color(1.0, 0.84, 0.0), coin_texture, 6, 1, 0) - "apple": - var heal_amount = 20.0 - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.GREEN, items_texture, 20, 14, (8 * 20) + 10) - "banana": - var heal_amount = 20.0 - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.YELLOW, items_texture, 20, 14, (8 * 20) + 11) - "cherry": - var heal_amount = 20.0 - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.RED, items_texture, 20, 14, (8 * 20) + 12) - "key": - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_item_pickup_notification(player, "+1 KEY", Color.YELLOW, items_texture, 20, 14, (13 * 20) + 10) + # If item_data is provided, use it to show item notification + if not item_data.is_empty(): + var chest_item = Item.new(item_data) + var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null + var display_text = chest_item.item_name.to_upper() + var item_color = Color.WHITE + + if chest_item.item_type == Item.ItemType.Restoration: + item_color = Color.GREEN + elif chest_item.item_type == Item.ItemType.Equippable: + item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) + + if items_texture: + _show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame) + else: + _show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0) + else: + # Fallback to old loot type system (for backwards compatibility) + match loot_type_str: + "coin": + var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") + _show_item_pickup_notification(player, "+1 COIN", Color(1.0, 0.84, 0.0), coin_texture, 6, 1, 0) + "apple": + var heal_amount = 20.0 + var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.GREEN, items_texture, 20, 14, (8 * 20) + 10) + "banana": + var heal_amount = 20.0 + var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.YELLOW, items_texture, 20, 14, (8 * 20) + 11) + "cherry": + var heal_amount = 20.0 + var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.RED, items_texture, 20, 14, (8 * 20) + 12) + "key": + var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + _show_item_pickup_notification(player, "+1 KEY", Color.YELLOW, items_texture, 20, 14, (13 * 20) + 10) func _show_item_pickup_notification(player: Node, text: String, text_color: Color, item_texture: Texture2D = null, sprite_hframes: int = 1, sprite_vframes: int = 1, sprite_frame: int = 0): # Show item graphic and text above player's head for 0.5s, then fade out over 0.5s diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index 4b0688e..b742ef1 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -29,6 +29,7 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main @onready var selection_rectangle: Panel = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/SelectionRectangle @onready var info_panel: Control = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/InfoPanel @onready var info_label: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/InfoPanel/InfoLabel +@onready var stats_label: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsLabel @onready var label_base_stats: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelBaseStats @onready var label_base_stats_value: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelBaseStatsValue @onready var label_derived_stats: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelDerivedStats @@ -161,6 +162,11 @@ func _update_stats(): var char_stats = local_player.character_stats + # Update race/class in stats label + if stats_label: + var race_text = char_stats.race + stats_label.text = "Stats - " + race_text + # Update base stats label_base_stats_value.text = str(char_stats.level) + "\n\n" + \ str(int(char_stats.hp)) + "/" + str(int(char_stats.maxhp)) + "\n" + \ diff --git a/src/scripts/item.gd b/src/scripts/item.gd index 879dd9d..20f5114 100644 --- a/src/scripts/item.gd +++ b/src/scripts/item.gd @@ -63,6 +63,7 @@ func save(): "description": description, "spritePath": spritePath, "equipmentPath": equipmentPath, + "colorReplacements": colorReplacements, "spriteFrame": spriteFrame, "modifiers": modifiers, "duration": duration, @@ -121,4 +122,6 @@ func load(iDic: Dictionary): can_have_multiple_of = iDic.get("can_have_multiple_of") if iDic.has("weight"): weight = iDic.get("weight") + if iDic.has("colorReplacements"): + colorReplacements = iDic.get("colorReplacements", []) pass diff --git a/src/scripts/item_loot_helper.gd b/src/scripts/item_loot_helper.gd index 67c338a..9c9500e 100644 --- a/src/scripts/item_loot_helper.gd +++ b/src/scripts/item_loot_helper.gd @@ -14,10 +14,21 @@ static func spawn_item_loot(item: Item, position: Vector2, entities_node: Node, push_error("ItemLootHelper: Could not load loot.tscn scene!") return null - # Generate random velocity for physics - var random_angle = randf() * PI * 2 - var random_force = randf_range(50.0, 100.0) - var random_velocity_z = randf_range(80.0, 120.0) + # Generate deterministic random velocity for physics using dungeon seed + # This ensures loot bounces the same on all clients + var loot_rng = RandomNumberGenerator.new() + var base_seed = 0 + if game_world and "dungeon_seed" in game_world: + base_seed = game_world.dungeon_seed + # Create unique seed for this loot item: dungeon_seed + position hash + counter + # Use position hash to make seed unique per spawn location + var pos_hash = hash(str(int(position.x)) + "_" + str(int(position.y))) + var loot_seed = base_seed + pos_hash + 20000 # Offset to avoid collisions with enemy loot + loot_rng.seed = loot_seed + + var random_angle = loot_rng.randf() * PI * 2 + var random_force = loot_rng.randf_range(50.0, 100.0) + var random_velocity_z = loot_rng.randf_range(80.0, 120.0) var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force # Find safe spawn position if game_world is provided diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 9cdaa53..0c7400b 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -21,11 +21,22 @@ var is_airborne: bool = true var velocity_set_by_spawner: bool = false # Track if velocity was set externally # Bounce physics -var bounce_restitution: float = 0.6 # How much bounce energy is retained (0-1) +var bounce_restitution: float = 0.6 # How much bounce energy is retained (0-1) - matches old code var min_bounce_velocity: float = 40.0 # Minimum velocity needed to bounce -var friction: float = 25.0 # Friction when on ground (increased to dampen faster) +var friction: float = 8.0 # Friction when on ground (lower = more gradual slowdown, matches old code) var bounce_timer: float = 0.0 # Prevent rapid bounce sounds +# Multiplayer sync and prediction +var sync_timer: float = 0.0 # Timer for periodic position/velocity sync +var sync_interval: float = 0.05 # Sync every 0.05 seconds (20 times per second) for smoother sync +var last_sync_time: float = 0.0 # Track last server sync time for reconciliation +var server_position: Vector2 = Vector2.ZERO # Last server-authoritative position +var server_velocity: Vector2 = Vector2.ZERO # Last server-authoritative velocity +var server_position_z: float = 0.0 # Last server-authoritative Z position +var server_velocity_z: float = 0.0 # Last server-authoritative Z velocity +var prediction_error_threshold: float = 10.0 # Distance threshold before correcting (pixels) +var correction_smoothing: float = 0.3 # Lerp factor for smooth correction (0-1, lower = smoother) + # Loot properties var coin_value: int = 1 var heal_amount: float = 20.0 @@ -77,11 +88,13 @@ func _ready(): # Adjust bounce properties based on loot type if loot_type == LootType.COIN: - bounce_restitution = 0.4 # Reduced from 0.6 to dampen more + bounce_restitution = 0.6 # Matches old code - more bouncy min_bounce_velocity = 40.0 + friction = 8.0 # Lower friction for coins - more gradual slowdown else: - bounce_restitution = 0.2 # Reduced from 0.3 to dampen more + bounce_restitution = 0.3 # Lower bounce for food items min_bounce_velocity = 60.0 + friction = 12.0 # Slightly higher friction for food items func _setup_sprite(): if not sprite: @@ -171,100 +184,174 @@ func _physics_process(delta): if collected: return - # Update bounce timer - if bounce_timer > 0.0: - bounce_timer -= delta - if bounce_timer < 0: - bounce_timer = 0.0 + var is_client = multiplayer.has_multiplayer_peer() and not is_multiplayer_authority() + var is_server = not multiplayer.has_multiplayer_peer() or is_multiplayer_authority() - # Update Z-axis physics - if is_airborne: - # Apply gravity to Z-axis - acceleration_z = -300.0 # Gravity - velocity_z += acceleration_z * delta + # Server (authority): Run physics normally + if is_server: + # Update bounce timer + if bounce_timer > 0.0: + bounce_timer -= delta + if bounce_timer < 0: + bounce_timer = 0.0 - # CRITICAL: Apply damping to velocity_z to lerp it towards 0 (prevents infinite bouncing) - # Dampen more when velocity is small (closer to ground) but allow normal bounces first - var damping_factor = 8.0 # How quickly velocity_z approaches 0 (allow more visible bounces) - if abs(velocity_z) < 25.0: # More aggressive damping for very small velocities only - damping_factor = 20.0 - velocity_z = lerpf(velocity_z, 0.0, 1.0 - exp(-damping_factor * delta)) - - position_z += velocity_z * delta - - # Apply air resistance to slow down horizontal movement while airborne - velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-8.0 * delta)) - - # Ground collision and bounce (skip if collected to prevent bounce sounds) - if position_z <= 0.0: - position_z = 0.0 + # Update Z-axis physics + if is_airborne: + # Apply gravity to Z-axis (matches old code) + acceleration_z = -300.0 # Gravity + velocity_z += acceleration_z * delta + position_z += velocity_z * delta - # Apply friction when on ground (dampen X/Y momentum faster) - velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) - - # Check if we should bounce (only if not collected and velocity is significant) - # Allow bouncing but ensure it eventually stops - if not collected and abs(velocity_z) > min_bounce_velocity: - # Bounce on floor - # Only play bounce sound if bounce is significant enough and timer has elapsed - # CRITICAL: Only play sound if velocity is large enough and coin is actually falling (downward) - if loot_type == LootType.COIN and bounce_timer == 0.0 and abs(velocity_z) > 50.0 and velocity_z < 0.0: - # Play bounce sound for coins (only for significant downward velocities) - if sfx_coin_bounce: - # Adjust volume based on bounce velocity (softer for smaller bounces) - var volume_multiplier = clamp(abs(velocity_z) / 100.0, 0.3, 1.0) - sfx_coin_bounce.volume_db = -3.0 + (-12.0 * (1.0 - volume_multiplier)) - sfx_coin_bounce.play() - bounce_timer = 0.12 # Prevent rapid bounce sounds but allow reasonable bounce rate + # Ground collision and bounce (matches old code - simpler, no aggressive damping) + if position_z <= 0.0: + position_z = 0.0 - velocity_z = - velocity_z * bounce_restitution + # Apply friction ONLY when on ground (matches old code behavior) + velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) - # CRITICAL: Force stop bouncing if velocity is too small after bounce (prevent micro-bounces) - # Use a lower threshold to allow a few more bounces before stopping - if abs(velocity_z) < min_bounce_velocity * 0.5: + # Check if we should bounce (simpler logic matching old code) + if not collected and abs(velocity_z) > min_bounce_velocity: + # Play bounce sound for coins (matches old code volume formula) + if loot_type == LootType.COIN and bounce_timer == 0.0 and velocity_z < 0.0: + if sfx_coin_bounce: + # Old code formula: -1 + (-10 - (velocityZ * 0.1)) + # Adjusted for negative velocity_z + sfx_coin_bounce.volume_db = -1.0 + (-10.0 - (abs(velocity_z) * 0.1)) + sfx_coin_bounce.play() + bounce_timer = 0.08 # Matches old code timing + + # Simple bounce (matches old code) + velocity_z = -velocity_z * bounce_restitution + is_airborne = true # Still bouncing + else: + # Velocity too small or collected - stop bouncing velocity_z = 0.0 is_airborne = false - else: - is_airborne = true # Still bouncing - else: - # Velocity too small or collected - stop bouncing - velocity_z = 0.0 - is_airborne = false - else: - is_airborne = false - # Ensure velocity_z is zero when on ground - velocity_z = 0.0 - # Apply friction even when not airborne (on ground) - velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) + else: + is_airborne = false + # Ensure velocity_z is zero when on ground + velocity_z = 0.0 + # Apply friction when on ground (matches old code) + velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) + + # Move and check for collisions + move_and_slide() + + # Check for wall collisions (skip if collected to prevent bounce sounds) + # Matches old code behavior - simpler wall bounce without aggressive velocity reduction + if not collected: + for i in get_slide_collision_count(): + var collision = get_slide_collision(i) + if collision: + var collider = collision.get_collider() + # Only bounce off walls, not players (players are detected via PickupArea) + if collider and not collider.is_in_group("player"): + # Check if velocity is too small before bouncing (prevent infinite micro-bounces) + var velocity_magnitude = velocity.length() + if velocity_magnitude < 15.0: # If velocity is very small, stop bouncing + velocity = Vector2.ZERO + continue # Skip bounce and sound + + # Bounce off walls (matches old code - no aggressive velocity reduction) + var normal = collision.get_normal() + velocity = velocity.bounce(normal) # Old code didn't reduce velocity here + + # Play bounce sound for coins hitting walls (matches old code) + if loot_type == LootType.COIN and bounce_timer == 0.0: + if sfx_coin_bounce: + sfx_coin_bounce.volume_db = -5.0 + sfx_coin_bounce.play() + bounce_timer = 0.08 # Matches old code timing + + # Update visual position based on Z + _update_visuals() + + # Animate coin rotation (always animate, even when not airborne) + if loot_type == LootType.COIN: + _animate_coin(delta) + + # Server: Periodically sync position/velocity to clients (sync more frequently when airborne) + sync_timer += delta + # Sync more frequently when airborne (bouncing), less when settled + var current_interval = sync_interval if is_airborne else sync_interval * 2.0 + if sync_timer >= current_interval: + sync_timer = 0.0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_physics"): + var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 + if loot_id >= 0: + game_world._rpc_to_ready_peers("_sync_loot_physics", [loot_id, global_position, velocity, position_z, velocity_z]) - # Move and check for collisions - move_and_slide() - - # Check for wall collisions (skip if collected to prevent bounce sounds) - if not collected: - for i in get_slide_collision_count(): - var collision = get_slide_collision(i) - if collision: - var collider = collision.get_collider() - # Only bounce off walls, not players (players are detected via PickupArea) - if collider and not collider.is_in_group("player"): - # Bounce off walls - var normal = collision.get_normal() - velocity = velocity.bounce(normal) * 0.5 # Reduce velocity more after bounce (was 0.8) - - # Play bounce sound for coins hitting walls - if loot_type == LootType.COIN and bounce_timer == 0.0: + # Client (prediction): Run physics locally for smooth movement, then reconcile with server + elif is_client: + # Run physics locally (client-side prediction) - same logic as server + # Update bounce timer + if bounce_timer > 0.0: + bounce_timer -= delta + if bounce_timer < 0: + bounce_timer = 0.0 + + # Update Z-axis physics + if is_airborne: + # Apply gravity to Z-axis (matches server) + acceleration_z = -300.0 + velocity_z += acceleration_z * delta + position_z += velocity_z * delta + + # Ground collision and bounce (matches server logic) + if position_z <= 0.0: + position_z = 0.0 + velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) + + if not collected and abs(velocity_z) > min_bounce_velocity: + # Play bounce sound for coins + if loot_type == LootType.COIN and bounce_timer == 0.0 and velocity_z < 0.0: if sfx_coin_bounce: - sfx_coin_bounce.volume_db = -5.0 + sfx_coin_bounce.volume_db = -1.0 + (-10.0 - (abs(velocity_z) * 0.1)) sfx_coin_bounce.play() bounce_timer = 0.08 - - # Update visual position based on Z - _update_visuals() - - # Animate coin rotation (always animate, even when not airborne) - if loot_type == LootType.COIN: - _animate_coin(delta) + velocity_z = -velocity_z * bounce_restitution + is_airborne = true + else: + velocity_z = 0.0 + is_airborne = false + else: + is_airborne = false + velocity_z = 0.0 + velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) + + # Move and check for collisions + move_and_slide() + + # Check for wall collisions + if not collected: + for i in get_slide_collision_count(): + var collision = get_slide_collision(i) + if collision: + var collider = collision.get_collider() + if collider and not collider.is_in_group("player"): + var velocity_magnitude = velocity.length() + if velocity_magnitude < 15.0: + velocity = Vector2.ZERO + continue + var normal = collision.get_normal() + velocity = velocity.bounce(normal) + if loot_type == LootType.COIN and bounce_timer == 0.0: + if sfx_coin_bounce: + sfx_coin_bounce.volume_db = -5.0 + sfx_coin_bounce.play() + bounce_timer = 0.08 + + # Update visuals + _update_visuals() + + # Animate coin rotation + if loot_type == LootType.COIN: + _animate_coin(delta) + + # Reconcile with server state if available (called from game_world._sync_loot_physics) + # Server state is stored in server_position, server_velocity, etc. variables + # Reconciliation happens in game_world._reconcile_loot_state() func _update_z_physics(delta): position_z += velocity_z * delta diff --git a/src/scripts/matchbox_client.gd b/src/scripts/matchbox_client.gd index ae23b31..16f7624 100644 --- a/src/scripts/matchbox_client.gd +++ b/src/scripts/matchbox_client.gd @@ -11,7 +11,17 @@ signal connection_succeeded(was_reconnecting: bool) signal webrtc_ready() # Emitted when WebRTC mesh is set up after Welcome message const MATCHBOX_SERVER = "wss://matchbox.thefirstboss.com" -const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" +# STUN/TURN server configuration +# COTURN server on ports 3478/3479 with relay ports 49150:49500 +const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3478" +# TURN server configuration (Coturn with long-term credentials) +# Using standard TURN port 3478 (UDP/TCP) and 3479 (TLS) +const TURN_SERVER = "turn:ruinborn.thefirstboss.com:3478" # Standard TURN port +# Note: Relay ports 49160:49500 are configured on the COTURN server side +# Realm: ruinborn.thefirstboss.com +# Credentials: myuser:mypassword (long-term credentials with lt-cred-mech) +const TURN_USERNAME = "myuser" # TURN username (long-term credentials) +const TURN_PASSWORD = "mypassword" # TURN password (long-term credentials) var websocket: WebSocketPeer = null var webrtc_peer: WebRTCMultiplayerPeer = null @@ -27,13 +37,15 @@ var pending_offers: Dictionary = {} # peer_id -> offer data var waiting_for_peer_id: bool = false # Client flag: waiting for host to assign peer ID var queued_signaling_messages: Array = [] # Queue for signaling messages received before peer ID assignment var connection_failed_emitted: bool = false # Prevent multiple connection_failed emissions +var ice_candidates_sent: Dictionary = {} # peer_id -> count of ICE candidates sent +var ice_candidates_received: Dictionary = {} # peer_id -> count of ICE candidates received var retry_count: int = 0 # Number of retry attempts var max_retries: int = 3 # Maximum retry attempts (for initial connection) var retry_timer: float = 0.0 # Timer for retry backoff var retry_delay: float = 5.0 # Initial retry delay in seconds var is_retrying: bool = false # Whether we're currently retrying var host_reconnect_timer: float = 0.0 # Timer for host reconnection -var host_reconnect_delay: float = 60.0 # Host reconnection delay: 1 minute +var host_reconnect_delay: float = 5.0 # Host reconnection delay: 5 seconds (reduced from 60.0) var host_reconnect_count: int = 0 # Number of host reconnection attempts var max_host_retries: int = 10 # Maximum host reconnection attempts var is_host_reconnecting: bool = false # Whether host is in reconnection mode @@ -82,8 +94,6 @@ func _check_and_emit_peer_connected(peer_id: int): var connection_state = pc.get_connection_state() var signaling_state = pc.get_signaling_state() - log_print("MatchboxClient: Checking connection for peer " + str(peer_id) + " (connection: " + str(connection_state) + ", signaling: " + str(signaling_state) + ")") - # If signaling state is STABLE (0) or connection is CONNECTING/CONNECTED, emit peer_connected # This allows the joiner to proceed even if the connection isn't fully established yet # The connection will complete during gameplay @@ -97,11 +107,9 @@ func _check_and_emit_peer_connected(peer_id: int): # For joiners, check if multiplayer system sees the peer # If the connection is CONNECTED (2), the peer should be available soon if connection_state == 2: # CONNECTED - log_print("MatchboxClient: Connection is CONNECTED, checking if peer is available for RPCs") call_deferred("_check_multiplayer_peer_available", peer_id) else: - # Connection not ready yet, try again after a short delay - log_print("MatchboxClient: Connection not ready for peer " + str(peer_id) + " - will retry") + # Connection not ready yet, try again after a short delay (silently retry) get_tree().create_timer(0.5).timeout.connect(func(): _check_and_emit_peer_connected(peer_id)) func _check_multiplayer_peer_available(peer_id: int): @@ -115,12 +123,11 @@ func _check_multiplayer_peer_available(peer_id: int): # Check if the peer is in the multiplayer peer list var peers = multiplayer.get_peers() if peer_id in peers: - log_print("MatchboxClient: Multiplayer system recognizes peer " + str(peer_id)) # The multiplayer.peer_connected signal should fire automatically # But if it doesn't, we might need to manually trigger it - # For now, just log - NetworkManager will handle connection_succeeded + # For now, just proceed - NetworkManager will handle connection_succeeded + pass else: - log_print("MatchboxClient: Multiplayer system doesn't recognize peer " + str(peer_id) + " yet - will retry") # Retry after a short delay (max 5 seconds) var peer_check_retry_count = get_meta("peer_check_retry_count", 0) if peer_check_retry_count < 10: # Max 10 retries (5 seconds) @@ -265,7 +272,7 @@ func _process(_delta): log_print("MatchboxClient: Connection closing...") func _handle_message(message: String): - log_print("MatchboxClient: Received message: " + message) + # Only log message content if there's an error - reduce console spam var json = JSON.new() var error = json.parse(message) if error != OK: @@ -273,7 +280,6 @@ func _handle_message(message: String): return var data = json.data - log_print("MatchboxClient: Parsed data: " + str(data)) # Matchbox protocol uses direct keys: IdAssigned, NewPeer, PeerLeft, Signal if data.has("IdAssigned"): @@ -622,20 +628,63 @@ func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: var pc = WebRTCPeerConnection.new() - # Configure STUN server + # Configure STUN and TURN servers according to Godot documentation format + # Each server should be a separate entry in the iceServers array + var ice_servers = [] + + # Add STUN server as separate entry + if not STUN_SERVER.is_empty(): + var stun_config = { + "urls": [STUN_SERVER] # urls must be an array + } + ice_servers.append(stun_config) + + # Add TURN server as separate entry with credentials + if not TURN_SERVER.is_empty(): + # For TURN, create URLs array with both UDP and TCP transports + var turn_urls = [] + if TURN_SERVER.begins_with("turns:"): + # TURNS over TLS: primarily TCP + turn_urls.append(TURN_SERVER + "?transport=tcp") + else: + # Standard TURN: try both UDP and TCP + turn_urls.append(TURN_SERVER + "?transport=udp") + turn_urls.append(TURN_SERVER + "?transport=tcp") + + # Create TURN server configuration + var turn_config = { + "urls": turn_urls # urls must be an array + } + + # Add credentials if configured (for long-term credentials) + if not TURN_USERNAME.is_empty() and not TURN_PASSWORD.is_empty(): + turn_config["username"] = TURN_USERNAME + turn_config["credential"] = TURN_PASSWORD + + ice_servers.append(turn_config) + var config = { - "iceServers": [ - { - "urls": [STUN_SERVER] - } - ] + "iceServers": ice_servers } + # Log ICE server configuration + log_print("MatchboxClient: Configuring ICE servers for peer " + str(peer_id) + ":") + for i in range(ice_servers.size()): + var server = ice_servers[i] + var urls = server.get("urls", []) + var username = server.get("username", "") + var credential = server.get("credential", "") + var has_credential = not credential.is_empty() + log_print(" ICE Server " + str(i) + ": " + str(urls) + (", username: " + username if not username.is_empty() else "") + (", has credential: " + str(has_credential))) + var error = pc.initialize(config) if error != OK: log_error("MatchboxClient: Failed to initialize peer connection: " + str(error)) return null + # Log the actual config that was used (for debugging) + log_print("MatchboxClient: Peer connection initialized for peer " + str(peer_id) + " with config: " + str(config)) + # Connect signals # Note: session_description_created signal has signature (type: String, sdp: String) # We need to wrap it in a lambda to pass peer_id @@ -649,21 +698,12 @@ func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: log_error("MatchboxClient: Failed to connect ice_candidate_created signal: " + str(ice_connected)) log_print("MatchboxClient: Signals connected for peer " + str(peer_id) + " (session: " + str(signal_connected) + ", ice: " + str(ice_connected) + ")") - # Create a data channel for multiplayer communication - # This is required before creating an offer - var data_channel = pc.create_data_channel("game", { - "ordered": true - }) - if not data_channel: - log_error("MatchboxClient: Failed to create data channel for peer " + str(peer_id)) - return null - - log_print("MatchboxClient: Created data channel for peer " + str(peer_id) + " (channel: " + str(data_channel) + ")") - peer_connections[peer_id] = pc # IMPORTANT: Add peer connection to WebRTC mesh BEFORE starting offer/answer exchange # The peer connection must be in STATE_NEW when added to the mesh + # Note: Do NOT create data channels manually - WebRTCMultiplayerPeer.add_peer() + # automatically creates the required channels (reliable, unreliable, ordered) add_peer_to_mesh(peer_id) # In a full mesh, the peer with the lower ID creates the offer to the peer with the higher ID @@ -697,6 +737,10 @@ func _create_offer_for_peer(peer_id: int): pc.poll() log_print("MatchboxClient: Peer connection state after create_offer: " + str(pc.get_connection_state())) + # Check ICE gathering state (if available) + # Note: Godot's WebRTC might not expose ICE gathering state directly + log_print("MatchboxClient: Waiting for ICE candidates to be generated...") + # Signal should be connected from _create_peer_connection log_print("MatchboxClient: Signal is connected, waiting for callback...") # Force another poll to ensure the signal fires @@ -795,12 +839,52 @@ func _handle_ice_candidate(peer_id: int, signal_data: Dictionary): if candidate.is_empty(): return + # Log ICE candidate type to help debug STUN/TURN connectivity + var candidate_type = "unknown" + if candidate.contains("typ host"): + candidate_type = "host (local)" + elif candidate.contains("typ srflx"): + candidate_type = "srflx (STUN)" + elif candidate.contains("typ relay"): + candidate_type = "relay (TURN)" + elif candidate.contains("typ prflx"): + candidate_type = "prflx (peer-reflexive)" + + # Log non-host candidates to see if STUN/TURN is working + if candidate_type != "host (local)": + log_print("MatchboxClient: Received ICE candidate for peer " + str(peer_id) + " - Type: " + candidate_type) + + # Track ICE candidates received + if not ice_candidates_received.has(peer_id): + ice_candidates_received[peer_id] = 0 + ice_candidates_received[peer_id] += 1 + var error = pc.add_ice_candidate(sdp_mid, sdp_mline_index, candidate) if error != OK: log_error("MatchboxClient: Failed to add ICE candidate: " + str(error)) return func _on_session_description_created(peer_id: int, type: String, sdp: String): + # According to Godot documentation, we MUST call set_local_description() + # after session_description_created, otherwise ice_candidate_created won't be emitted! + var pc = peer_connections.get(peer_id) + if not pc: + log_error("MatchboxClient: No peer connection found for peer " + str(peer_id) + " when setting local description") + return + + # Set the local description - this is REQUIRED for ICE candidates to be generated + var set_error = pc.set_local_description(type, sdp) + if set_error != OK: + log_error("MatchboxClient: Failed to set local description for peer " + str(peer_id) + ": " + str(set_error)) + return + + log_print("MatchboxClient: Set local " + type + " for peer " + str(peer_id) + " - ICE candidates should now be generated") + + # Only log SDP summary, not full details - reduces console spam + # Check for ICE candidates in SDP + var candidate_count = sdp.split("a=candidate:").size() - 1 + log_print("MatchboxClient: Created " + type + " for peer " + str(peer_id) + " with " + str(candidate_count) + " ICE candidate(s) in SDP") + # Matchbox protocol uses "From" with UUID, not "id" with peer ID # Find the UUID for this peer_id var target_uuid = "" @@ -826,10 +910,8 @@ func _on_session_description_created(peer_id: int, type: String, sdp: String): # If we're a joiner (not hosting) and we just created an answer, # emit peer_connected after sending it (deferred to ensure answer is sent first) if not is_hosting and type == "answer": - log_print("MatchboxClient: Joiner created answer for peer " + str(peer_id) + " - will emit peer_connected after sending") # Use call_deferred to ensure the answer is sent first, then check connection state call_deferred("_check_and_emit_peer_connected", peer_id) - log_print("MatchboxClient: Sending Signal message: type=" + type + " from=" + my_uuid + " to=" + target_uuid + " sdp length=" + str(sdp.length())) _send_message(message) # If we're a joiner and just sent an answer, check connection and emit peer_connected @@ -844,7 +926,150 @@ func _on_session_description_created(peer_id: int, type: String, sdp: String): # Also check immediately - the connection might be ready call_deferred("_check_and_emit_peer_connected", peer_id) +# Connection status methods for HUD display +func is_matchbox_connected() -> bool: + """Check if Matchbox WebSocket is connected""" + return is_network_connected and websocket != null and websocket.get_ready_state() == WebSocketPeer.STATE_OPEN + +func get_ice_connection_status() -> Dictionary: + """Get ICE connection status for all peers + Returns: Dictionary with peer_id -> {"connected": bool, "connection_state": int, "signaling_state": int, "candidates_sent": int, "candidates_received": int} + """ + var status = {} + for peer_id in peer_connections.keys(): + var pc = peer_connections[peer_id] + if pc: + var connection_state = pc.get_connection_state() if pc.has_method("get_connection_state") else -1 + var signaling_state = pc.get_signaling_state() if pc.has_method("get_signaling_state") else -1 + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + status[peer_id] = { + "connected": connection_state == 2, # CONNECTED + "connection_state": connection_state, + "signaling_state": signaling_state, + "candidates_sent": ice_candidates_sent.get(peer_id, 0), + "candidates_received": ice_candidates_received.get(peer_id, 0) + } + return status + +func are_data_channels_connected() -> bool: + """Check if data channels are connected for at least one peer + Returns true if any peer has data channels open (connected flag in WebRTCMultiplayerPeer) + Also checks peer_connections directly and actual channel ready states (following Godot demo pattern) + """ + if not webrtc_peer: + return false + + # First, check if we have any peers with data channels open via multiplayer.get_peers() + if multiplayer and multiplayer.has_multiplayer_peer(): + var peers = multiplayer.get_peers() + if not peers.is_empty(): + # Check if at least one peer has connected data channels + for peer_id in peers: + if webrtc_peer.has_peer(peer_id): + var peer_info = webrtc_peer.get_peer(peer_id) + if peer_info: + # Check connected flag first (fast path) + if peer_info.get("connected", false): + # Also verify channels are actually open (following demo pattern) + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + var all_channels_open = true + for channel in channels: + if channel != null: + # Check if channel has ready_state method + if channel.has_method("get_ready_state"): + var ready_state = channel.get_ready_state() + # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if ready_state != 1: # Not OPEN + all_channels_open = false + break + elif "ready_state" in channel: + var ready_state = channel.get("ready_state") + if ready_state != 1: # Not OPEN + all_channels_open = false + break + if all_channels_open: + return true + else: + # No channels array, but connected flag is true - assume connected + return true + + # Fallback: Check peer_connections directly (useful for joiner where host might not be in get_peers() yet) + # If we have peer connections with CONNECTED state, check data channels + for peer_id in peer_connections.keys(): + var pc = peer_connections[peer_id] + if pc and pc.has_method("get_connection_state"): + var connection_state = pc.get_connection_state() + var signaling_state = pc.get_signaling_state() if pc.has_method("get_signaling_state") else -1 + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + # Signaling state: 0=STABLE, 1=HAVE_LOCAL_OFFER, 2=HAVE_REMOTE_OFFER, etc. + # If connection is CONNECTED (2) OR signaling is STABLE (0) with connection >= CONNECTING (1), + # data channels should be working (following same logic as ICE status check) + if connection_state == 2: # CONNECTED + # Verify via webrtc_peer if available + if webrtc_peer.has_peer(peer_id): + var peer_info = webrtc_peer.get_peer(peer_id) + if peer_info: + # Check connected flag + if peer_info.get("connected", false): + # Also verify channels are actually open + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + var all_channels_open = true + for channel in channels: + if channel != null: + if channel.has_method("get_ready_state"): + var ready_state = channel.get_ready_state() + if ready_state != 1: # Not OPEN + all_channels_open = false + break + elif "ready_state" in channel: + var ready_state = channel.get("ready_state") + if ready_state != 1: # Not OPEN + all_channels_open = false + break + if all_channels_open: + return true + else: + # Connection state is CONNECTED and connected flag is true + # Data channels should be working even if channels array isn't available + return true + # Connection state is CONNECTED - even if peer not in webrtc_peer yet or connected flag not set, + # if the WebRTC connection state is CONNECTED, data channels should be working + # This is especially important for joiner where timing might be different + return true + elif signaling_state == 0 and connection_state >= 1: # STABLE signaling and at least CONNECTING + # Signaling is STABLE (offer/answer exchange complete) and connection is at least CONNECTING + # This means data channels should be working (same logic as ICE status) + # This helps catch cases where connection_state hasn't reached CONNECTED yet but channels are open + return true + + return false + func _on_ice_candidate_created(peer_id: int, media: String, index: int, candidate_name: String): + # Log all ICE candidates to help debug connection issues + var candidate_type = "unknown" + if candidate_name.contains("typ host"): + candidate_type = "host (local)" + elif candidate_name.contains("typ srflx"): + candidate_type = "srflx (STUN)" + elif candidate_name.contains("typ relay"): + candidate_type = "relay (TURN)" + elif candidate_name.contains("typ prflx"): + candidate_type = "prflx (peer-reflexive)" + + # Log all candidates (including host) to debug connection issues + log_print("MatchboxClient: ICE candidate created for peer " + str(peer_id) + " - Type: " + candidate_type + ", Media: " + media) + # Log full candidate string for TURN debugging (first 200 chars to avoid spam) + if candidate_type == "relay (TURN)": + var candidate_preview = candidate_name.substr(0, 200) if candidate_name.length() > 200 else candidate_name + log_print("MatchboxClient: TURN candidate details: " + candidate_preview) + + # Track ICE candidates sent + if not ice_candidates_sent.has(peer_id): + ice_candidates_sent[peer_id] = 0 + ice_candidates_sent[peer_id] += 1 + # Matchbox protocol uses "From" with UUID, not "id" with peer ID # Find the UUID for this peer_id var target_uuid = "" @@ -963,14 +1188,20 @@ func _poll_and_check_offer(peer_id: int): if not pc: return - # Poll multiple times to ensure async operations complete - log_print("MatchboxClient: Performing additional polls for peer " + str(peer_id) + " offer creation") - for i in range(10): + # Poll multiple times to ensure async operations complete and ICE candidates are gathered + log_print("MatchboxClient: Performing additional polls for peer " + str(peer_id) + " offer creation (waiting for ICE candidates)") + var candidate_count_before = ice_candidates_sent.get(peer_id, 0) + for i in range(20): # Increased from 10 to 20 to give more time for ICE gathering pc.poll() # Small delay between polls - await get_tree().create_timer(0.01).timeout + await get_tree().create_timer(0.05).timeout # Increased from 0.01 to 0.05 + var candidate_count_after = ice_candidates_sent.get(peer_id, 0) + if candidate_count_after > candidate_count_before: + log_print("MatchboxClient: ICE candidates detected during polling (count: " + str(candidate_count_after) + ")") + candidate_count_before = candidate_count_after - log_print("MatchboxClient: Finished additional polling for peer " + str(peer_id)) + var final_candidate_count = ice_candidates_sent.get(peer_id, 0) + log_print("MatchboxClient: Finished additional polling for peer " + str(peer_id) + " (total ICE candidates sent: " + str(final_candidate_count) + ")") func _handle_connection_failure(): """Handle connection failure and decide whether to retry""" @@ -1018,6 +1249,16 @@ func _attempt_host_reconnect(): host_reconnect_count += 1 log_print("MatchboxClient: Host retrying connection to room: " + room_name + " (attempt " + str(host_reconnect_count) + "/" + str(max_host_retries) + ")") + + # Show chat message for host reconnection attempt + var network_manager = get_parent() + if network_manager: + var game_world = network_manager.get_tree().get_first_node_in_group("game_world") + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", "Host reconnecting... (attempt " + str(host_reconnect_count) + "/" + str(max_host_retries) + ")") + connection_failed_emitted = false # Create new WebSocket connection @@ -1027,7 +1268,17 @@ func _attempt_host_reconnect(): if error != OK: log_error("MatchboxClient: Host failed to reconnect: " + str(error)) - # Will retry again in 1 minute (if under max retries) + # Show chat message for failed attempt + if network_manager: + var game_world = network_manager.get_tree().get_first_node_in_group("game_world") + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + if host_reconnect_count < max_host_retries: + chat_ui.add_local_message("System", "Host reconnection failed, retrying in " + str(int(host_reconnect_delay)) + " seconds...") + else: + chat_ui.add_local_message("System", "Host reconnection failed, max retries reached") + # Will retry again (if under max retries) if host_reconnect_count >= max_host_retries: is_host_reconnecting = false return @@ -1068,8 +1319,9 @@ func add_peer_to_mesh(peer_id: int): return var error = webrtc_peer.add_peer(pc, peer_id) + if error != OK: log_error("MatchboxClient: Failed to add peer to mesh: " + str(error)) return - log_print("MatchboxClient: Added peer " + str(peer_id) + " to WebRTC mesh") + log_print("MatchboxClient: Added peer " + str(peer_id) + " to WebRTC mesh") \ No newline at end of file diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 439eb12..79e79ac 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -15,7 +15,10 @@ signal rooms_fetched(rooms: Array) # Forwarded from room_registry const DEFAULT_PORT = 21212 const MAX_PLAYERS = 8 const MATCHBOX_SERVER = "wss://matchbox.thefirstboss.com" -const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" +const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3478" +const TURN_SERVER = "turn:ruinborn.thefirstboss.com:3478" +const TURN_USERNAME = "myuser" +const TURN_PASSWORD = "mypassword" var players_info = {} # Dictionary of peer_id -> {local_player_count: int, player_names: []} var local_player_count = 1 # How many local players on this machine @@ -32,7 +35,7 @@ var reconnection_room_id: String = "" # Store room_id for reconnection var reconnection_level: int = 0 # Store level for reconnection var reconnection_attempting: bool = false # Track if we're attempting to reconnect var reconnection_timer: float = 0.0 # Timer for reconnection delay -const RECONNECTION_DELAY: float = 2.0 # Delay before attempting reconnection +const RECONNECTION_DELAY: float = 1.0 # Delay before attempting reconnection (reduced from 2.0) # Logging - use LogManager for categorized logging func log_print(message: String): @@ -651,8 +654,59 @@ func _on_matchbox_peer_connected(peer_id: int): log_print("NetworkManager: Joiner - already in game scene, connection_succeeded was already emitted") ) - # Don't emit player_connected here - wait for multiplayer.peer_connected signal - # which fires when the peer is actually available for RPCs + # On web, multiplayer.peer_connected might not fire for either host or joiner + # So we need to manually emit player_connected as fallback + # For host: emit player_connected for client peers (peer_id > 1) + # For joiner: emit player_connected for host (peer_id == 1) + + # Host fallback: emit player_connected for client connections + if is_hosting and peer_id > 1: + var host_fallback_key = "player_connected_fallback_host_" + str(peer_id) + if has_meta(host_fallback_key): + return + + set_meta(host_fallback_key, true) + + # Wait a bit to see if multiplayer.peer_connected fires first + get_tree().create_timer(1.0).timeout.connect(func(): + if not is_inside_tree(): + return + + # Check if player_connected was already emitted (check if multiplayer peer is recognized) + if players_info.has(peer_id): + log_print("NetworkManager: Emitting player_connected as fallback for peer " + str(peer_id) + " (host, Matchbox connection established, multiplayer.peer_connected didn't fire)") + player_connected.emit(peer_id, players_info[peer_id]) + remove_meta(host_fallback_key) + ) + + # Joiner fallback: emit player_connected for host connection + if not is_hosting and peer_id == 1: + # Register player info if not already registered + if not players_info.has(peer_id): + players_info[peer_id] = { + "local_player_count": 1, # Default, will be updated via RPC + "player_names": _generate_player_names(1, peer_id) + } + + var fallback_key = "player_connected_fallback_" + str(peer_id) + if has_meta(fallback_key): + # Already scheduled fallback emission + return + + set_meta(fallback_key, true) + + # Wait a bit to see if multiplayer.peer_connected fires first + get_tree().create_timer(1.0).timeout.connect(func(): + if not is_inside_tree(): + return + + # Check if player_connected was already emitted by checking if multiplayer peer is recognized + # On web, we can't reliably check this, so we just emit anyway (idempotency handled in GameWorld) + if players_info.has(peer_id): + log_print("NetworkManager: Emitting player_connected as fallback for peer " + str(peer_id) + " (Matchbox connection established, multiplayer.peer_connected didn't fire)") + player_connected.emit(peer_id, players_info[peer_id]) + remove_meta(fallback_key) + ) func _emit_connection_succeeded_safe(): """Safely emit connection_succeeded signal - checks if node is still valid""" @@ -693,13 +747,26 @@ func _attempt_reconnect(): log_print("NetworkManager: Attempting to reconnect to room: " + reconnection_room_id) + # Show chat message for reconnection attempt + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", "Attempting to reconnect...") + # Attempt to reconnect using the stored room_id var success = join_game(reconnection_room_id) if not success: - log_error("NetworkManager: Reconnection attempt failed, will retry") + log_error("NetworkManager: Reconnection attempt failed, will retry in " + str(RECONNECTION_DELAY) + " seconds") # Retry after delay reconnection_attempting = true reconnection_timer = RECONNECTION_DELAY + + # Show chat message for failed attempt + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", "Reconnection failed, retrying in " + str(int(RECONNECTION_DELAY)) + " seconds...") func fetch_available_rooms() -> bool: """Fetch available rooms from the registry""" @@ -736,17 +803,47 @@ func get_webrtc_peer() -> WebRTCMultiplayerPeer: return multiplayer.multiplayer_peer as WebRTCMultiplayerPeer return null -# Create a WebRTC peer connection with STUN server configured +# Create a WebRTC peer connection with STUN and TURN servers configured func create_peer_connection() -> WebRTCPeerConnection: var peer_connection = WebRTCPeerConnection.new() - # Configure STUN server for NAT traversal + # Configure STUN and TURN servers according to Godot documentation format + # Each server should be a separate entry in the iceServers array + var ice_servers = [] + + # Add STUN server as separate entry + if not STUN_SERVER.is_empty(): + var stun_config = { + "urls": [STUN_SERVER] # urls must be an array + } + ice_servers.append(stun_config) + + # Add TURN server as separate entry with credentials + if not TURN_SERVER.is_empty(): + # For TURN, create URLs array with both UDP and TCP transports + var turn_urls = [] + if TURN_SERVER.begins_with("turns:"): + # TURNS over TLS: primarily TCP + turn_urls.append(TURN_SERVER + "?transport=tcp") + else: + # Standard TURN: try both UDP and TCP + turn_urls.append(TURN_SERVER + "?transport=udp") + turn_urls.append(TURN_SERVER + "?transport=tcp") + + # Create TURN server configuration + var turn_config = { + "urls": turn_urls # urls must be an array + } + + # Add credentials if configured (for long-term credentials) + if not TURN_USERNAME.is_empty() and not TURN_PASSWORD.is_empty(): + turn_config["username"] = TURN_USERNAME + turn_config["credential"] = TURN_PASSWORD + + ice_servers.append(turn_config) + var config = { - "iceServers": [ - { - "urls": [STUN_SERVER] - } - ] + "iceServers": ice_servers } var error = peer_connection.initialize(config) @@ -754,7 +851,7 @@ func create_peer_connection() -> WebRTCPeerConnection: push_error("Failed to initialize WebRTC peer connection: " + str(error)) return null - print("WebRTC peer connection initialized with STUN server: ", STUN_SERVER) + print("WebRTC peer connection initialized with STUN/TURN servers: ", STUN_SERVER, " / ", TURN_SERVER) return peer_connection # Add a peer connection for WebRTC mesh networking diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 8eac5e6..2d9bbce 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -6,7 +6,7 @@ extends CharacterBody2D var character_stats: CharacterStats var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/stats -@export var move_speed: float = 100.0 +@export var move_speed: float = 80.0 @export var grab_range: float = 20.0 @export var throw_force: float = 150.0 @export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) @@ -27,6 +27,7 @@ var virtual_joystick_input: Vector2 = Vector2.ZERO # Virtual joystick input from # Interaction var held_object = null var grab_offset = Vector2.ZERO +var grab_distance: float = 0.0 # Distance from player to object when grabbed (for placement) var can_grab = true var is_lifting = false # True when object is lifted above head var is_pushing = false # True when holding button to push/pull @@ -62,6 +63,7 @@ var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var is_attacking: bool = false var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version +var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn") var blood_scene = preload("res://scenes/blood_clot.tscn") @@ -171,7 +173,7 @@ const ANIMATIONS = { }, "STAFF": { "frames": [13, 14, 15], - "frameDurations": [200, 200, 400], + "frameDurations": [100, 100, 300], "loop": false, "nextAnimation": "IDLE" }, @@ -259,6 +261,10 @@ func _ready(): # Initialize character stats system _initialize_character_stats() + # CRITICAL: Duplicate shader materials for hair/facial hair to prevent shared state + # If materials are shared between players, changing one affects all + _duplicate_sprite_materials() + # Set up player appearance (randomized based on stats) _setup_player_appearance() @@ -378,28 +384,161 @@ func _ready(): can_send_rpcs = true print("Player ", name, " is now ready to send RPCs (is_server: ", multiplayer.is_server(), ")") +func _duplicate_sprite_materials(): + # Duplicate shader materials for sprites that use tint parameters + # This prevents shared material state between players + if sprite_hair and sprite_hair.material: + sprite_hair.material = sprite_hair.material.duplicate() + if sprite_facial_hair and sprite_facial_hair.material: + sprite_facial_hair.material = sprite_facial_hair.material.duplicate() + if sprite_eyes and sprite_eyes.material: + sprite_eyes.material = sprite_eyes.material.duplicate() + if sprite_eyelashes and sprite_eyelashes.material: + sprite_eyelashes.material = sprite_eyelashes.material.duplicate() + # Also duplicate materials for equipment sprites that use color replacements + if sprite_boots and sprite_boots.material: + sprite_boots.material = sprite_boots.material.duplicate() + if sprite_armour and sprite_armour.material: + sprite_armour.material = sprite_armour.material.duplicate() + if sprite_headgear and sprite_headgear.material: + sprite_headgear.material = sprite_headgear.material.duplicate() + if sprite_weapon and sprite_weapon.material: + sprite_weapon.material = sprite_weapon.material.duplicate() + func _initialize_character_stats(): + # Create character_stats if it doesn't exist + if not character_stats: + character_stats = CharacterStats.new() + character_stats.character_type = "player" + character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index) + + # Initialize health/mana from stats + character_stats.hp = character_stats.maxhp + character_stats.mp = character_stats.maxmp + + # Connect signals + if character_stats: + character_stats.level_up_stats.connect(_on_level_up_stats) + character_stats.character_changed.connect(_on_character_changed) + + # Ensure equipment starts empty (players spawn bare) + character_stats.equipment["mainhand"] = null + character_stats.equipment["offhand"] = null + character_stats.equipment["headgear"] = null + character_stats.equipment["armour"] = null + character_stats.equipment["boots"] = null + character_stats.equipment["accessory"] = null + # Create deterministic RNG based on peer_id and local_index for sync across clients + # Add session-based randomness so appearance changes each game session appearance_rng = RandomNumberGenerator.new() - var seed_value = hash(str(peer_id) + "_" + str(local_player_index)) + var session_seed = 0 + # Use dungeon seed if available (for multiplayer sync), otherwise use time for variety + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world and game_world.dungeon_seed != 0: + session_seed = game_world.dungeon_seed + else: + session_seed = Time.get_ticks_msec() # Different each game session (for single-player) + # Mark that we need to re-initialize appearance when dungeon_seed becomes available + if multiplayer.has_multiplayer_peer(): + set_meta("needs_appearance_reset", true) + var seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed)) appearance_rng.seed = seed_value - # Create character stats - character_stats = CharacterStats.new() - character_stats.character_type = "player" - character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index) + # Stats will be randomized AFTER race is set in _setup_player_appearance() + +func _reinitialize_appearance_with_seed(_seed_value: int): + # Re-initialize appearance with the correct dungeon_seed + # This is called when a joiner receives dungeon_seed after players were already spawned + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or game_world.dungeon_seed == 0: + return # Still no seed, skip - # Randomize base stats (deterministic) - _randomize_stats() + # Only re-initialize if this player was spawned before dungeon_seed was available + # Check if appearance needs to be reset (set in _initialize_character_stats) + if not has_meta("needs_appearance_reset"): + return # Appearance was already initialized with correct seed, skip - # Initialize health/mana from stats - character_stats.hp = character_stats.maxhp - character_stats.mp = character_stats.maxmp + # Ensure character_stats exists before trying to modify appearance + if not character_stats: + LogManager.log_error("Player " + str(name) + " _reinitialize_appearance_with_seed: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) + return - # Connect signals - if character_stats: - character_stats.level_up_stats.connect(_on_level_up_stats) - character_stats.character_changed.connect(_on_character_changed) + # Save current state (race, stats, equipment) before re-initializing + # We need to preserve these because they might have been set correctly already + var saved_race = character_stats.race + var saved_stats = { + "str": character_stats.baseStats.str, + "dex": character_stats.baseStats.dex, + "int": character_stats.baseStats.int, + "end": character_stats.baseStats.end, + "wis": character_stats.baseStats.wis, + "cha": character_stats.baseStats.cha, + "lck": character_stats.baseStats.lck, + "hp": character_stats.hp, + "maxhp": character_stats.maxhp, + "mp": character_stats.mp, + "maxmp": character_stats.maxmp, + "kills": character_stats.kills, + "coin": character_stats.coin, + "exp": character_stats.xp, # CharacterStats uses 'xp' not 'exp' + "level": character_stats.level + } + # Deep copy equipment + var saved_equipment = {} + for slot_name in character_stats.equipment.keys(): + var item = character_stats.equipment[slot_name] + saved_equipment[slot_name] = item.save() if item else null + # Save inventory + var saved_inventory = [] + for item in character_stats.inventory: + saved_inventory.append(item.save() if item else null) + + # Re-seed the RNG with the correct dungeon_seed + var session_seed = game_world.dungeon_seed + var new_seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed)) + appearance_rng.seed = new_seed_value + + # Re-run appearance setup with the correct seed + # This will re-randomize visual appearance (skin, hair, facial hair, eyes, etc.) + _setup_player_appearance() + + # Restore saved race, stats, and equipment (preserve them from before re-initialization) + character_stats.setRace(saved_race) # Restore original race + character_stats.baseStats.str = saved_stats.str + character_stats.baseStats.dex = saved_stats.dex + character_stats.baseStats.int = saved_stats.int + character_stats.baseStats.end = saved_stats.end + character_stats.baseStats.wis = saved_stats.wis + character_stats.baseStats.cha = saved_stats.cha + character_stats.baseStats.lck = saved_stats.lck + character_stats.hp = saved_stats.hp + character_stats.maxhp = saved_stats.maxhp + character_stats.mp = saved_stats.mp + character_stats.maxmp = saved_stats.maxmp + character_stats.kills = saved_stats.kills + character_stats.coin = saved_stats.coin + character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp' + character_stats.level = saved_stats.level + + # Restore equipment + for slot_name in saved_equipment.keys(): + var item_data = saved_equipment[slot_name] + character_stats.equipment[slot_name] = Item.new(item_data) if item_data else null + + # Restore inventory + character_stats.inventory.clear() + for item_data in saved_inventory: + if item_data: + character_stats.inventory.append(Item.new(item_data)) + + # Re-apply appearance to sprites to show the new visual appearance + _apply_appearance_to_sprites() + + # Clear the flag so we don't re-initialize again + remove_meta("needs_appearance_reset") + + LogManager.log("Player " + str(name) + " appearance re-initialized with dungeon_seed " + str(session_seed), LogManager.CATEGORY_GAMEPLAY) func _randomize_stats(): # Randomize base stats within reasonable ranges @@ -412,52 +551,174 @@ func _randomize_stats(): character_stats.baseStats.cha = appearance_rng.randi_range(8, 12) character_stats.baseStats.lck = appearance_rng.randi_range(8, 12) + # Apply race-based stat modifiers + match character_stats.race: + "Dwarf": + # Dwarf: Higher STR, Medium DEX, lower INT, lower WIS, lower LCK + character_stats.baseStats.str += 3 + character_stats.baseStats.int -= 2 + character_stats.baseStats.wis -= 2 + character_stats.baseStats.lck -= 2 + "Elf": + # Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK + character_stats.baseStats.dex += 3 + character_stats.baseStats.int -= 2 + character_stats.baseStats.lck += 2 + "Human": + # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK + character_stats.baseStats.str -= 2 + character_stats.baseStats.dex -= 2 + character_stats.baseStats.int += 3 + character_stats.baseStats.wis += 3 + character_stats.baseStats.lck -= 2 + # Stats randomized (verbose logging removed) func _setup_player_appearance(): # Randomize appearance - players spawn "bare" (naked, no equipment) # But with randomized hair, facial hair, eyes, etc. + + # Ensure character_stats exists before setting appearance + if not character_stats: + LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) + return + + # Randomize race first (affects appearance constraints and stats) + var races = ["Dwarf", "Elf", "Human"] + var selected_race = races[appearance_rng.randi() % races.size()] + character_stats.setRace(selected_race) + + # Randomize stats AFTER race is set (race affects stat modifiers) + _randomize_stats() + # Randomize skin (human only for players) - var skin_index = appearance_rng.randi_range(0, 6) # 0-6 for Human1-Human7 + # Weighted random: Human1 has highest chance, Human7 has lowest chance + # Weights: Human1=7, Human2=6, Human3=5, Human4=4, Human5=3, Human6=2, Human7=1 (total=28) + var weights = [7, 6, 5, 4, 3, 2, 1] # Higher weight = higher chance + var total_weight = 28 + var random_value = appearance_rng.randi() % total_weight + var skin_index = 0 + var cumulative = 0 + for i in range(weights.size()): + cumulative += weights[i] + if random_value < cumulative: + skin_index = i + break character_stats.setSkin(skin_index) # Randomize hairstyle (0 = none, 1-12 = various styles) var hair_style = appearance_rng.randi_range(0, 12) character_stats.setHair(hair_style) - # Randomize hair color + # Randomize hair color - vibrant and weird colors! var hair_colors = [ Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), # Brown Color(0.8, 0.6, 0.4), # Blonde Color(0.6, 0.3, 0.1), # Dark brown Color(0.9, 0.7, 0.5), # Light blonde Color(0.2, 0.2, 0.2), # Dark gray - Color(0.5, 0.5, 0.5) # Gray + Color(0.5, 0.5, 0.5), # Gray + Color(0.5, 0.8, 0.2), # Snot green + Color(0.9, 0.5, 0.1), # Orange + Color(0.8, 0.3, 0.9), # Purple + Color(1.0, 0.9, 0.2), # Yellow + Color(1.0, 0.5, 0.8), # Pink + Color(0.9, 0.2, 0.2), # Red + Color(0.2, 0.9, 0.9), # Bright cyan + Color(0.6, 0.2, 0.9), # Magenta + Color(0.9, 0.7, 0.2), # Gold + Color(0.3, 0.9, 0.3), # Bright green + Color(0.2, 0.2, 0.9), # Bright blue + Color(0.9, 0.4, 0.6), # Hot pink + Color(0.5, 0.2, 0.8), # Deep purple + Color(0.9, 0.6, 0.1) # Amber ] character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) - # Randomize facial hair (0 = none, 1-3 = beard/mustache styles) - var facial_hair_style = appearance_rng.randi_range(0, 3) + # Randomize facial hair based on race constraints + var facial_hair_style = 0 + match selected_race: + "Dwarf": + # Dwarf: must have mustache or beard (1-3, not 0) + facial_hair_style = appearance_rng.randi_range(1, 3) + "Elf": + # Elf: cannot have facial hair (always 0) + facial_hair_style = 0 + "Human": + # Human: only mustache or no facial hair (0 or 3) + facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0 character_stats.setFacialHair(facial_hair_style) - # Randomize facial hair color (usually matches hair) + # Randomize facial hair color (usually matches hair, but can be different) if facial_hair_style > 0: - character_stats.setFacialHairColor(character_stats.hair_color) + if appearance_rng.randf() < 0.3: # 30% chance for different color + character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) + else: + character_stats.setFacialHairColor(character_stats.hair_color) # Randomize eyes (0 = none, 1-14 = various eye colors) var eye_style = appearance_rng.randi_range(1, 14) # Always have eyes character_stats.setEyes(eye_style) + # Randomize eye color - vibrant and weird colors! + # 75% chance for white, 25% chance for other colors + var white_color = Color(0.9, 0.9, 0.9) # White + var other_eye_colors = [ + Color(0.1, 0.1, 0.1), # Black + Color(0.2, 0.3, 0.8), # Blue + Color(0.3, 0.7, 0.9), # Cyan + Color(0.5, 0.8, 0.2), # Snot green + Color(0.9, 0.5, 0.1), # Orange + Color(0.8, 0.3, 0.9), # Purple + Color(1.0, 0.9, 0.2), # Yellow + Color(1.0, 0.5, 0.8), # Pink + Color(0.9, 0.2, 0.2), # Red + Color(0.2, 0.9, 0.9), # Bright cyan + Color(0.6, 0.2, 0.9), # Magenta + Color(0.9, 0.7, 0.2), # Gold + Color(0.3, 0.9, 0.3), # Bright green + Color(0.2, 0.2, 0.9), # Bright blue + Color(0.9, 0.4, 0.6), # Hot pink + Color(0.5, 0.2, 0.8), # Deep purple + Color(0.9, 0.6, 0.1) # Amber + ] + if appearance_rng.randf() < 0.75: # 75% chance for white + character_stats.setEyeColor(white_color) + else: # 25% chance for other colors + character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()]) + # Randomize eyelashes (0 = none, 1-8 = various styles) var eyelash_style = appearance_rng.randi_range(0, 8) character_stats.setEyeLashes(eyelash_style) - # Randomize ears/addons (0 = none, 1-7 = elf ears) - var ear_style = appearance_rng.randi_range(0, 7) - if appearance_rng.randf() < 0.2: # 20% chance for elf ears - character_stats.setEars(ear_style) - else: - character_stats.setEars(0) # No ears + # Randomize eyelash color - vibrant and weird colors too! + var eyelash_colors = [ + Color(0.1, 0.1, 0.1), # Black + Color(0.2, 0.2, 0.2), # Dark gray + Color(0.3, 0.2, 0.15), # Dark brown + Color(0.4, 0.3, 0.2), # Brown + Color(0.5, 0.8, 0.2), # Snot green + Color(0.9, 0.5, 0.1), # Orange + Color(0.8, 0.3, 0.9), # Purple + Color(1.0, 0.9, 0.2), # Yellow + Color(1.0, 0.5, 0.8), # Pink + Color(0.9, 0.2, 0.2), # Red + Color(0.9, 0.9, 0.9), # White + Color(0.6, 0.2, 0.9) # Magenta + ] + if eyelash_style > 0: + character_stats.setEyelashColor(eyelash_colors[appearance_rng.randi() % eyelash_colors.size()]) + + # Randomize ears/addons based on race + match selected_race: + "Elf": + # Elf: always gets elf ears (ElfEars1 to 7 based on skin number) + # skin_index is 0-6 (Human1-7), ear styles are 1-7 (ElfEars1-7) + var elf_ear_style = skin_index + 1 # Convert 0-6 to 1-7 + character_stats.setEars(elf_ear_style) + _: + # Other races: no ears + character_stats.setEars(0) # Apply appearance to sprite layers _apply_appearance_to_sprites() @@ -479,30 +740,40 @@ func _apply_appearance_to_sprites(): # Boots if sprite_boots: var equipped_boots = character_stats.equipment["boots"] - if equipped_boots and equipped_boots.equipmentPath != "": + # Only render boots if it's actually boots equipment (not a weapon or other type) + if equipped_boots and equipped_boots.equipment_type == Item.EquipmentType.BOOTS and equipped_boots.equipmentPath != "": var boots_texture = load(equipped_boots.equipmentPath) if boots_texture: sprite_boots.texture = boots_texture sprite_boots.hframes = 35 sprite_boots.vframes = 8 + # Apply color replacements if available + _apply_color_replacements(sprite_boots, equipped_boots) else: sprite_boots.texture = null + _clear_color_replacements(sprite_boots) else: sprite_boots.texture = null + _clear_color_replacements(sprite_boots) # Armour if sprite_armour: var equipped_armour = character_stats.equipment["armour"] - if equipped_armour and equipped_armour.equipmentPath != "": + # Only render armour if it's actually armour equipment (not a weapon) + if equipped_armour and equipped_armour.equipment_type == Item.EquipmentType.ARMOUR and equipped_armour.equipmentPath != "": var armour_texture = load(equipped_armour.equipmentPath) if armour_texture: sprite_armour.texture = armour_texture sprite_armour.hframes = 35 sprite_armour.vframes = 8 + # Apply color replacements if available + _apply_color_replacements(sprite_armour, equipped_armour) else: sprite_armour.texture = null + _clear_color_replacements(sprite_armour) else: sprite_armour.texture = null + _clear_color_replacements(sprite_armour) # Facial Hair if sprite_facial_hair: @@ -512,7 +783,16 @@ func _apply_appearance_to_sprites(): sprite_facial_hair.texture = facial_hair_texture sprite_facial_hair.hframes = 35 sprite_facial_hair.vframes = 8 - sprite_facial_hair.modulate = character_stats.facial_hair_color + # Use shader tint parameter instead of modulate + # Only update color if it's valid (not uninitialized black with alpha 0) + # This prevents hair colors from changing when joiners connect and sync triggers character_changed + var facial_hair_color = character_stats.facial_hair_color + if facial_hair_color != Color(0, 0, 0, 0): + if sprite_facial_hair.material and sprite_facial_hair.material is ShaderMaterial: + sprite_facial_hair.material.set_shader_parameter("tint", Vector4(facial_hair_color.r, facial_hair_color.g, facial_hair_color.b, facial_hair_color.a)) + else: + # Fallback to modulate if no shader material + sprite_facial_hair.modulate = facial_hair_color else: sprite_facial_hair.texture = null else: @@ -526,7 +806,16 @@ func _apply_appearance_to_sprites(): sprite_hair.texture = hair_texture sprite_hair.hframes = 35 sprite_hair.vframes = 8 - sprite_hair.modulate = character_stats.hair_color + # Use shader tint parameter instead of modulate + # Only update color if it's valid (not uninitialized black with alpha 0) + # This prevents hair colors from changing when joiners connect and sync triggers character_changed + var hair_color = character_stats.hair_color + if hair_color != Color(0, 0, 0, 0): + if sprite_hair.material and sprite_hair.material is ShaderMaterial: + sprite_hair.material.set_shader_parameter("tint", Vector4(hair_color.r, hair_color.g, hair_color.b, hair_color.a)) + else: + # Fallback to modulate if no shader material + sprite_hair.modulate = hair_color else: sprite_hair.texture = null else: @@ -540,6 +829,12 @@ func _apply_appearance_to_sprites(): sprite_eyes.texture = eyes_texture sprite_eyes.hframes = 35 sprite_eyes.vframes = 8 + # Use shader tint parameter for eye color + if sprite_eyes.material and sprite_eyes.material is ShaderMaterial: + sprite_eyes.material.set_shader_parameter("tint", Vector4(character_stats.eye_color.r, character_stats.eye_color.g, character_stats.eye_color.b, character_stats.eye_color.a)) + else: + # Fallback to modulate if no shader material + sprite_eyes.modulate = character_stats.eye_color else: sprite_eyes.texture = null else: @@ -553,6 +848,12 @@ func _apply_appearance_to_sprites(): sprite_eyelashes.texture = eyelash_texture sprite_eyelashes.hframes = 35 sprite_eyelashes.vframes = 8 + # Use shader tint parameter for eyelash color + if sprite_eyelashes.material and sprite_eyelashes.material is ShaderMaterial: + sprite_eyelashes.material.set_shader_parameter("tint", Vector4(character_stats.eyelash_color.r, character_stats.eyelash_color.g, character_stats.eyelash_color.b, character_stats.eyelash_color.a)) + else: + # Fallback to modulate if no shader material + sprite_eyelashes.modulate = character_stats.eyelash_color else: sprite_eyelashes.texture = null else: @@ -580,21 +881,108 @@ func _apply_appearance_to_sprites(): sprite_headgear.texture = headgear_texture sprite_headgear.hframes = 35 sprite_headgear.vframes = 8 + # Apply color replacements if available + _apply_color_replacements(sprite_headgear, equipped_headgear) else: sprite_headgear.texture = null + _clear_color_replacements(sprite_headgear) else: sprite_headgear.texture = null + _clear_color_replacements(sprite_headgear) # Weapon (Mainhand) - # NOTE: Weapons should NEVER use equipmentPath - they don't have character sprite sheets - # Weapons are only displayed as inventory icons (spritePath), not as character sprite layers + # NOTE: Weapons NEVER change the Sprite2DWeapon sprite... + # but they can apply color changes!!! if sprite_weapon: - sprite_weapon.texture = null # Weapons don't use character sprite layers + var equipped_weapon = null + if character_stats and character_stats.equipment.has("mainhand"): + equipped_weapon = character_stats.equipment["mainhand"] + + if equipped_weapon and equipped_weapon.weapon_type == Item.WeaponType.STAFF and equipped_weapon.equipmentPath != "": + _apply_weapon_color_replacements(sprite_weapon, equipped_weapon) + else: + _clear_weapon_color_replacements(sprite_weapon) # Appearance applied (verbose logging removed) +func _apply_color_replacements(sprite: Sprite2D, item: Item) -> void: + # Apply color replacements using shader parameters + if not sprite or not item: + return + + if not sprite.material or not sprite.material is ShaderMaterial: + return + + if not item.colorReplacements or item.colorReplacements.size() == 0: + return + + var shader_material = sprite.material as ShaderMaterial + for index in range(item.colorReplacements.size()): + var color_replacement: Dictionary = item.colorReplacements[index] + if color_replacement.has("original") and color_replacement.has("replace"): + var original_color = color_replacement["original"] as Color + var replace_color = color_replacement["replace"] as Color + shader_material.set_shader_parameter("original_" + str(index), original_color) + shader_material.set_shader_parameter("replace_" + str(index), replace_color) + +func _clear_color_replacements(sprite: Sprite2D) -> void: + # Clear color replacement shader parameters + if not sprite or not sprite.material or not sprite.material is ShaderMaterial: + return + + var shader_material = sprite.material as ShaderMaterial + # Clear up to 10 replacement slots (should be enough) + for index in range(10): + shader_material.set_shader_parameter("original_" + str(index), Color(0, 0, 0, 0)) + shader_material.set_shader_parameter("replace_" + str(index), Color(0, 0, 0, 0)) + +func _apply_weapon_color_replacements(sprite: Sprite2D, item: Item) -> void: + # Apply color replacements for staff colors only (RGB 209,142,54 and RGB 192,112,31) + if not sprite or not item: + return + + if not sprite.material or not sprite.material is ShaderMaterial: + return + + if not item.colorReplacements or item.colorReplacements.size() == 0: + return + + var shader_material = sprite.material as ShaderMaterial + # Staff colors that should be replaced on the weapon sprite + var staff_colors = [ + Color(209/255.0, 142/255.0, 54/255.0), + Color(192/255.0, 112/255.0, 31/255.0) + ] + + var replacement_index = 0 + for color_replacement in item.colorReplacements: + if color_replacement.has("original") and color_replacement.has("replace"): + var original_color = color_replacement["original"] as Color + # Only apply replacements for staff colors + for staff_color in staff_colors: + # Check if this replacement matches a staff color (with some tolerance) + if _colors_similar(original_color, staff_color, 0.1): + var replace_color = color_replacement["replace"] as Color + shader_material.set_shader_parameter("original_" + str(replacement_index), original_color) + shader_material.set_shader_parameter("replace_" + str(replacement_index), replace_color) + replacement_index += 1 + break # Found match, move to next replacement + +func _clear_weapon_color_replacements(sprite: Sprite2D) -> void: + # Clear weapon color replacement shader parameters (same as regular clear) + _clear_color_replacements(sprite) + +func _colors_similar(color1: Color, color2: Color, tolerance: float = 0.1) -> bool: + # Check if two colors are similar within tolerance + var r_diff = abs(color1.r - color2.r) + var g_diff = abs(color1.g - color2.g) + var b_diff = abs(color1.b - color2.b) + return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance + func _on_character_changed(_char: CharacterStats): # Update appearance when character stats change (e.g., equipment) + # Only update appearance-related sprites (equipment, not hair/facial hair colors) + # Hair and facial hair colors should NEVER change after initial setup _apply_appearance_to_sprites() # Sync equipment changes to other clients (when authority player changes equipment) @@ -670,20 +1058,46 @@ func _get_log_prefix() -> String: func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool: var space_state = get_world_2d().direct_space_state + + # Get the actual collision shape and its transform (including position offset) var placed_shape = _get_collision_shape_for(placed_obj) + var placed_shape_transform = _get_collision_shape_transform(placed_obj, place_pos) + if not placed_shape: # Fallback to 16x16 placed_shape = RectangleShape2D.new() placed_shape.size = Vector2(16, 16) + placed_shape_transform = Transform2D(0.0, place_pos) + # Check if the placed object's collision shape would collide with anything + # This includes: walls, other objects, and players var params = PhysicsShapeQueryParameters2D.new() params.shape = placed_shape - params.transform = Transform2D(0.0, place_pos) - params.collision_mask = 1 | 2 | 64 # Players, objects, walls - params.exclude = [self, placed_obj] + params.transform = placed_shape_transform + params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64) - var hits = space_state.intersect_shape(params, 8) - return hits.is_empty() + # CRITICAL: Exclude self, the object being placed, and make sure to exclude it properly + # The object might still be in the scene tree with collision disabled, so we need to exclude it + var exclude_list = [self] + if placed_obj and is_instance_valid(placed_obj): + exclude_list.append(placed_obj) + params.exclude = exclude_list + + # Test the actual collision shape at the placement position + var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions + + # Debug: Log what we found + if hits.size() > 0: + print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos) + for i in min(hits.size(), 3): # Log first 3 collisions + var hit = hits[i] + if hit.has("collider"): + print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")") + if hit.has("rid"): + print(" - RID: ", hit.rid) + + # If any collisions found, placement is invalid + return hits.size() == 0 func _find_closest_place_pos(direction: Vector2, placed_obj: Node) -> Vector2: var dir = direction.normalized() @@ -692,18 +1106,33 @@ func _find_closest_place_pos(direction: Vector2, placed_obj: Node) -> Vector2: if dir.length() < 0.1: dir = Vector2.RIGHT - var player_extent = _get_collision_extent(self) - var obj_extent = _get_collision_extent(placed_obj) - # Start just outside player + object bounds - var start_dist = max(8.0, player_extent + obj_extent + 1.0) - var max_dist = start_dist + 32.0 - var step = 2.0 + # Use the stored grab distance if available, otherwise calculate a default + var target_distance = grab_distance + if target_distance <= 0.0: + # Fallback: calculate minimum distance if grab_distance wasn't stored + var player_extent = _get_collision_extent(self) + var obj_extent = _get_collision_extent(placed_obj) + target_distance = player_extent + obj_extent + 2.0 - var best_pos = global_position + dir * max_dist - for d in range(int(start_dist), int(max_dist) + 1, int(step)): - var test_pos = global_position + dir * float(d) + # Try placing at the exact grab distance first + var place_pos = global_position + dir * target_distance + if _can_place_down_at(place_pos, placed_obj): + return place_pos + + # If exact distance doesn't work, search nearby positions + # Search slightly closer and further to find valid placement + var search_range = 10.0 + var step = 0.5 + var best_pos = place_pos + + # Try closer positions first (prefer closer placement) + for offset in range(int(-search_range * 2), int(search_range * 2) + 1, int(step * 2)): + var test_dist = target_distance + (float(offset) / 2.0) + if test_dist < 2.0: # Don't get too close + continue + var test_pos = global_position + dir * test_dist if _can_place_down_at(test_pos, placed_obj): - return test_pos + return test_pos # Return first valid position return best_pos @@ -717,6 +1146,29 @@ func _get_collision_shape_for(node: Node) -> Shape2D: return shape_node.shape return null +func _get_collision_shape_transform(node: Node, world_pos: Vector2) -> Transform2D: + # Get the collision shape's local transform (position offset and rotation) + # and combine it with the world position + if not node: + return Transform2D(0.0, world_pos) + + var shape_node = node.get_node_or_null("CollisionShape2D") + if not shape_node: + shape_node = node.find_child("CollisionShape2D", true, false) + + if shape_node: + # Get the shape node's local position and rotation + var shape_local_pos = shape_node.position + var shape_rotation = shape_node.rotation + + # Create transform: rotation first, then translation + # The shape's local position is relative to the node, so add it to world_pos + var shape_transform = Transform2D(shape_rotation, world_pos + shape_local_pos) + return shape_transform + + # No shape node found, just use world position + return Transform2D(0.0, world_pos) + func _get_collision_extent(node: Node) -> float: var shape = _get_collision_shape_for(node) if shape is RectangleShape2D: @@ -1015,7 +1467,7 @@ func _physics_process(delta): # Immediately stop movement when controls are disabled (e.g., inventory opened) velocity = Vector2.ZERO # Reset animation to IDLE if not in a special state - if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD": + if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": if is_lifting: _set_animation("IDLE_HOLD") elif is_pushing: @@ -1249,7 +1701,7 @@ func _handle_input(): _set_animation("RUN_HOLD") elif is_pushing: _set_animation("RUN_PUSH") - elif current_animation != "SWORD": + elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": _set_animation("RUN") else: # Idle animations @@ -1263,7 +1715,7 @@ func _handle_input(): current_direction = push_direction_locked as Direction _update_cone_light_rotation() else: - if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD": + if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": _set_animation("IDLE") # Handle drag sound for interactable objects @@ -1506,6 +1958,8 @@ func _try_grab(): initial_grab_position = closest_body.global_position initial_player_position = global_position grab_offset = closest_body.position - position + # Store the distance from player to object when grabbed (for placement) + grab_distance = global_position.distance_to(closest_body.global_position) # Calculate push axis from grab direction (but don't move the object yet) var grab_direction = grab_offset.normalized() @@ -1700,6 +2154,7 @@ func _stop_pushing(): held_object = null grab_offset = Vector2.ZERO + grab_distance = 0.0 initial_grab_position = Vector2.ZERO initial_player_position = Vector2.ZERO print("Stopped pushing") @@ -1898,8 +2353,10 @@ func _place_down_object(): var place_pos = _find_closest_place_pos(last_movement_direction, held_object) var placed_obj = held_object + print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")") + if not _can_place_down_at(place_pos, placed_obj): - print("DEBUG: Place down blocked - space not free") + print("DEBUG: Place down blocked - space not free at ", place_pos) return # Clear state @@ -1964,12 +2421,18 @@ func _perform_attack(): equipped_weapon = character_stats.equipment["mainhand"] var is_bow = false - if equipped_weapon and equipped_weapon.weapon_type == Item.WeaponType.BOW: - is_bow = true + var is_staff = false + if equipped_weapon: + if equipped_weapon.weapon_type == Item.WeaponType.BOW: + is_bow = true + elif equipped_weapon.weapon_type == Item.WeaponType.STAFF: + is_staff = true # Play attack animation based on weapon if is_bow: _set_animation("BOW") + elif is_staff: + _set_animation("STAFF") else: _set_animation("SWORD") @@ -2033,6 +2496,9 @@ func _perform_attack(): var arrow_projectile = attack_arrow_scene.instantiate() get_parent().add_child(arrow_projectile) arrow_projectile.shoot(attack_direction, global_position, self) + # Play bow shoot sound + if has_node("SfxBowShoot"): + $SfxBowShoot.play() # Consume one arrow arrows.quantity -= 1 var remaining = arrows.quantity @@ -2048,9 +2514,25 @@ func _perform_attack(): print(name, " shot arrow! Arrows remaining: ", remaining) else: # Play bow animation but no projectile + # Play sound for trying to shoot without arrows + if has_node("SfxBowWithoutArrow"): + $SfxBowWithoutArrow.play() print(name, " tried to shoot but has no arrows!") + elif is_staff: + # Spawn staff projectile for staff weapons + if staff_projectile_scene and equipped_weapon: + var projectile = staff_projectile_scene.instantiate() + get_parent().add_child(projectile) + projectile.setup(attack_direction, self, final_damage, equipped_weapon) + # Store crit status for visual feedback + if is_crit: + projectile.set_meta("is_crit", true) + # Spawn projectile a bit in front of the player + var spawn_offset = attack_direction * 6.0 + projectile.global_position = global_position + spawn_offset + print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") else: - # Spawn sword projectile for non-bow weapons + # Spawn sword projectile for non-bow/staff weapons if sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) @@ -2059,7 +2541,7 @@ func _perform_attack(): if is_crit: projectile.set_meta("is_crit", true) # Spawn projectile a bit in front of the player - var spawn_offset = attack_direction * 10.0 # 10 pixels in front + var spawn_offset = attack_direction * 6.0 # 10 pixels in front projectile.global_position = global_position + spawn_offset print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") @@ -2190,16 +2672,104 @@ func _rpc_to_ready_peers(method: String, args: Array = []): # Clients: only send to peers marked ready by server if game_world and "clients_ready" in game_world: - for target_peer_id in multiplayer.get_peers(): - # Always allow sending to server (peer 1) + # Get peers list once to avoid multiple calls + var peers = multiplayer.get_peers() + + for target_peer_id in peers: + # Final check: verify peer is still in get_peers() right before sending + var current_peers = multiplayer.get_peers() + if target_peer_id not in current_peers: + continue + + # Always allow sending to server (peer 1), but check connection first if target_peer_id == 1: + # For WebRTC, verify connection before sending to server + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + if not _is_peer_connected_for_rpc(target_peer_id): + continue callv("rpc_id", [target_peer_id, method] + args) continue + + # Check if peer is ready and connected if game_world.clients_ready.has(target_peer_id) and game_world.clients_ready[target_peer_id]: + # For WebRTC, verify connection before sending + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + if not _is_peer_connected_for_rpc(target_peer_id): + continue callv("rpc_id", [target_peer_id, method] + args) else: - # Fallback: send to all peers - callv("rpc", [method] + args) + # Fallback: send to all peers (but still check connections for WebRTC) + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var peers = multiplayer.get_peers() + for target_peer_id in peers: + if _is_peer_connected_for_rpc(target_peer_id): + callv("rpc_id", [target_peer_id, method] + args) + else: + callv("rpc", [method] + args) + +func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: + """Check if a peer is still connected and has open data channels before sending RPC""" + if not multiplayer.has_multiplayer_peer(): + return false + + # Check if peer is in get_peers() + var peers = multiplayer.get_peers() + if target_peer_id not in peers: + return false + + # For WebRTC, check if data channels are actually open + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if not webrtc_peer.has_peer(target_peer_id): + return false + + var peer_info = webrtc_peer.get_peer(target_peer_id) + if not peer_info: + return false + + # Check if data channels are connected (this is the critical check) + var is_net_connected = peer_info.get("connected", false) + if not is_net_connected: + return false + + # Also check connection state to be extra safe + var connection_obj = peer_info.get("connection") + if connection_obj != null and typeof(connection_obj) == TYPE_INT: + var connection_val = int(connection_obj) + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + return false + + # Also verify channels array to ensure channels are actually open + # The "connected" flag might not update immediately when channels close + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + for channel in channels: + if channel != null: + # Check if channel has ready_state method + if channel.has_method("get_ready_state"): + var ready_state = channel.get_ready_state() + # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if ready_state != 1: # Not OPEN + return false + elif "ready_state" in channel: + var ready_state = channel.get("ready_state") + if ready_state != 1: # Not OPEN + return false + + # Also check matchbox_client connection state for additional verification + var network_manager = get_node_or_null("/root/NetworkManager") + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + if "peer_connections" in matchbox: + var pc = matchbox.peer_connections.get(target_peer_id) + if pc and pc.has_method("get_connection_state"): + var matchbox_conn_state = pc.get_connection_state() + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + return false + + return true # Network sync @rpc("any_peer", "unreliable") @@ -2237,24 +2807,68 @@ func _sync_attack(direction: int, attack_dir: Vector2): if not is_multiplayer_authority(): current_direction = direction as Direction - _set_animation("SWORD") - # Delay before spawning sword slash + # Determine weapon type for animation and projectile + var equipped_weapon = null + var is_staff = false + var is_bow = false + if character_stats and character_stats.equipment.has("mainhand"): + equipped_weapon = character_stats.equipment["mainhand"] + if equipped_weapon: + if equipped_weapon.weapon_type == Item.WeaponType.STAFF: + is_staff = true + elif equipped_weapon.weapon_type == Item.WeaponType.BOW: + is_bow = true + + # Set appropriate animation + if is_staff: + _set_animation("STAFF") + elif is_bow: + _set_animation("BOW") + else: + _set_animation("SWORD") + + # Delay before spawning projectile await get_tree().create_timer(0.15).timeout # Check again after delay - node might have been destroyed if not is_inside_tree() or not is_instance_valid(self): return - # Spawn sword projectile on client - if sword_projectile_scene: + # Spawn appropriate projectile on client + if is_staff and staff_projectile_scene and equipped_weapon: + var projectile = staff_projectile_scene.instantiate() + get_parent().add_child(projectile) + projectile.setup(attack_dir, self, 20.0, equipped_weapon) + # Spawn projectile a bit in front of the player + var spawn_offset = attack_dir * 10.0 # 10 pixels in front + projectile.global_position = global_position + spawn_offset + print(name, " performed synced staff attack!") + elif is_bow: + # For bow attacks, check if we have arrows (same logic as host) + var arrows = null + if character_stats and character_stats.equipment.has("offhand"): + var offhand_item = character_stats.equipment["offhand"] + if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: + arrows = offhand_item + + # Only spawn arrow if we have arrows (matches host behavior) + if arrows and arrows.quantity > 0: + if attack_arrow_scene: + var arrow_projectile = attack_arrow_scene.instantiate() + get_parent().add_child(arrow_projectile) + arrow_projectile.shoot(attack_dir, global_position, self) + print(name, " performed synced bow attack with arrow!") + else: + # No arrows - just play animation, no projectile (matches host behavior) + print(name, " performed synced bow attack without arrows (no projectile)") + elif sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self) # Spawn projectile a bit in front of the player var spawn_offset = attack_dir * 10.0 # 10 pixels in front projectile.global_position = global_position + spawn_offset - print(name, " performed synced attack!") @rpc("any_peer", "reliable") diff --git a/src/scripts/player_manager.gd b/src/scripts/player_manager.gd index cfd83c8..c0ac4ad 100644 --- a/src/scripts/player_manager.gd +++ b/src/scripts/player_manager.gd @@ -68,6 +68,19 @@ func spawn_player(peer_id: int, local_index: int): # Fallback if no spawn points spawn_pos = Vector2.ZERO + # CRITICAL: Verify spawn position is safe (on floor, not in wall) + # Use game_world's safety check if available + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_is_safe_spawn_position"): + if not game_world._is_safe_spawn_position(spawn_pos): + # Spawn position is not safe, find a nearby safe position + if game_world.has_method("_find_nearby_safe_spawn_position"): + var safe_pos = game_world._find_nearby_safe_spawn_position(spawn_pos, 128.0) + print("Player ", unique_id, " spawn position ", spawn_pos, " was unsafe, using safe position: ", safe_pos) + spawn_pos = safe_pos + else: + print("Player ", unique_id, " WARNING: Spawn position ", spawn_pos, " is not safe, but no safe position finder available!") + player.position = spawn_pos # Add to YSort node for automatic Y-sorting diff --git a/src/scripts/room_registry_client.gd b/src/scripts/room_registry_client.gd index 2aa6840..aa49a9b 100644 --- a/src/scripts/room_registry_client.gd +++ b/src/scripts/room_registry_client.gd @@ -119,7 +119,7 @@ func send_room_update(player_count: int, level: int = -1) -> bool: var json_string = JSON.stringify(json_data) var headers = ["Content-Type: application/json"] - log_print("RoomRegistry: Sending room update: " + json_string) + log_print("RoomRegistry: Sending room update (room: " + current_room + ", players: " + str(player_count) + ", level: " + str(level) + ")") var error = http_request_update.request(ROOM_UPDATE_URL, headers, HTTPClient.METHOD_POST, json_string) if error != OK: log_error("RoomRegistry: Failed to send room update: " + str(error)) diff --git a/src/scripts/staff_projectile.gd b/src/scripts/staff_projectile.gd new file mode 100644 index 0000000..428f763 --- /dev/null +++ b/src/scripts/staff_projectile.gd @@ -0,0 +1,235 @@ +extends Node2D + +# Staff Projectile - Travels away from player and deals damage (magic ball) + +@export var damage: float = 20.0 +@export var initial_speed: float = 300.0 # Faster than sword projectile +@export var deceleration: float = 600.0 # Slower deceleration (travels further) +@export var lifetime: float = 0.8 # Longer lifetime +@export var max_distance: float = 200.0 # Travels further + +var current_speed: float = 0.0 + +var travel_direction: Vector2 = Vector2.RIGHT +var elapsed_time: float = 0.0 +var distance_traveled: float = 0.0 +var player_owner: Node = null +var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) +var color_replacements: Array = [] # Color replacements from staff item + +@onready var sprite = $Sprite2D +@onready var hit_area = $Area2D + +func _ready(): + # Apply color replacements if available + _apply_color_replacements() + $SfxSwosh.play() + $AnimationPlayer.play("flying") + # Connect area signals + if hit_area: + hit_area.body_entered.connect(_on_body_entered) + +func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0, staff_item: Item = null): + travel_direction = direction.normalized() + player_owner = owner_player + damage = damage_value # Set damage from player + current_speed = initial_speed + + # Store color replacements from staff + if staff_item and staff_item.colorReplacements: + color_replacements = staff_item.colorReplacements + + # Rotate sprite to face travel direction + rotation = direction.angle() + + # Apply color replacements after setup (in case sprite wasn't ready yet) + _apply_color_replacements() + +func _apply_color_replacements(): + # Apply color replacements to projectile sprite using shader parameters + if not sprite or not sprite.material or not sprite.material is ShaderMaterial: + return + + if color_replacements.size() == 0: + return + + var shader_material = sprite.material as ShaderMaterial + # Filter for "magic" colors only (RGB 174,39,30; RGB 109,29,32; RGB 246,57,48) + # These are the colors that should be replaced on the projectile + var magic_colors = [ + Color(174/255.0, 39/255.0, 30/255.0), + Color(109/255.0, 29/255.0, 32/255.0), + Color(246/255.0, 57/255.0, 48/255.0) + ] + + var replacement_index = 0 + for color_replacement in color_replacements: + if color_replacement.has("original") and color_replacement.has("replace"): + var original_color = color_replacement["original"] as Color + # Only apply replacements for magic colors + for magic_color in magic_colors: + # Check if this replacement matches a magic color (with some tolerance) + if _colors_similar(original_color, magic_color, 0.1): + var replace_color = color_replacement["replace"] as Color + shader_material.set_shader_parameter("original_" + str(replacement_index), original_color) + shader_material.set_shader_parameter("replace_" + str(replacement_index), replace_color) + replacement_index += 1 + break # Found match, move to next replacement + +func _colors_similar(color1: Color, color2: Color, tolerance: float = 0.1) -> bool: + # Check if two colors are similar within tolerance + var r_diff = abs(color1.r - color2.r) + var g_diff = abs(color1.g - color2.g) + var b_diff = abs(color1.b - color2.b) + return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance + +func _physics_process(delta): + elapsed_time += delta + + # Check lifetime + if elapsed_time >= lifetime or distance_traveled >= max_distance: + $Area2D.set_deferred("monitoring", false) + self.visible = false + if $SfxImpactWall.playing: + await $SfxImpactWall.finished + if $SfxImpact.playing: + await $SfxImpact.finished + queue_free() + return + + # Decelerate + current_speed -= deceleration * delta + current_speed = max(0.0, current_speed) # Don't go negative + + # Move in travel direction + var movement = travel_direction * current_speed * delta + global_position += movement + distance_traveled += movement.length() + + # Fade out (based on speed) + var alpha = current_speed / initial_speed # 1.0 at start, 0.0 when stopped + if sprite: + sprite.modulate.a = alpha + +func _on_body_entered(body): + # Don't hit the owner + if body == player_owner: + return + + # Don't hit the same target twice - use Dictionary for O(1) lookup to prevent race conditions + if body in hit_targets: + return + + # CRITICAL: Only the projectile owner (authority) should deal damage + if player_owner and not player_owner.is_multiplayer_authority(): + return # Only the authority (creator) of the projectile can deal damage + + # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) + hit_targets[body] = true + + # Deal damage to players - call RPC to let victim apply damage on their client + if body.is_in_group("player") and body.has_method("rpc_take_damage"): + $SfxImpact.play() + var attacker_pos = player_owner.global_position if player_owner else global_position + var player_peer_id = body.get_multiplayer_authority() + if player_peer_id != 0: + if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): + body.rpc_take_damage(damage, attacker_pos) + else: + body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos) + else: + body.rpc_take_damage.rpc(damage, attacker_pos) + print("Staff projectile hit player: ", body.name, " for ", damage, " damage!") + + # Deal damage to enemies - only authority (creator) deals damage + elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): + var attacker_pos = player_owner.global_position if player_owner else global_position + var is_crit = get_meta("is_crit") if has_meta("is_crit") else false + + # Check hit chance (based on player's DEX stat) + var hit_roll = randf() + var hit_chance = 0.95 # Base hit chance + if player_owner and player_owner.character_stats: + hit_chance = player_owner.character_stats.hit_chance + var is_miss = hit_roll >= hit_chance + + if is_miss: + # Attack missed + print("Player MISSED enemy: ", body.name, "! (hit chance: ", hit_chance * 100.0, "%)") + if body.has_method("_show_damage_number"): + body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true + return + + # Hit successful + $SfxImpact.play() + + # Use game_world to route damage request + var game_world = get_tree().get_first_node_in_group("game_world") + var enemy_name = body.name + var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1 + + if game_world and game_world.has_method("_request_enemy_damage"): + if multiplayer.is_server(): + game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, is_crit) + else: + game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit) + else: + # Fallback + var enemy_peer_id = body.get_multiplayer_authority() + if enemy_peer_id != 0: + if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): + body.rpc_take_damage(damage, attacker_pos, is_crit) + else: + body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, is_crit) + else: + body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) + + var owner_name: String = "none" + var is_authority: bool = false + if player_owner: + owner_name = str(player_owner.name) + is_authority = player_owner.is_multiplayer_authority() + print("Staff projectile hit enemy: ", body.name, " for ", damage, " damage! (owner: ", owner_name, " is_authority: ", is_authority, ")") + return + + # Deal damage to boxes or other damageable objects + elif "health" in body: + body.health -= damage + $SfxImpact.play() + if body.health <= 0: + # Get object identifier + var obj_name = body.name + var obj_index = -1 + + if body.has_meta("object_index"): + obj_index = body.get_meta("object_index") + if obj_index >= 0: + obj_name = "InteractableObject_%d" % obj_index + + if not obj_name.begins_with("InteractableObject_") and obj_index < 0: + print("Staff projectile: Warning - object ", body.name, " doesn't have consistent naming!") + + # Sync break to server + if multiplayer.has_multiplayer_peer(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and is_instance_valid(game_world) and game_world.is_inside_tree() and game_world.has_method("_sync_object_break"): + if multiplayer.is_server(): + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [obj_name]) + print("Staff projectile synced box break to all clients: ", obj_name) + else: + game_world._sync_object_break.rpc_id(1, obj_name) + print("Staff projectile requested box break on server: ", obj_name, " (index: ", obj_index, ")") + else: + print("Staff projectile: GameWorld not ready, skipping box break sync for ", obj_name) + + # Break locally AFTER syncing + if body.has_method("_break_into_pieces"): + body._break_into_pieces() + print("Staff projectile broke box locally: ", body.name) + print("Staff projectile hit object: ", body.name) + + # Push the hit target away slightly (only for non-enemies) + if body is CharacterBody2D and not body.is_in_group("enemy"): + var knockback_dir = (body.global_position - global_position).normalized() + body.velocity = knockback_dir * 200.0 diff --git a/src/scripts/staff_projectile.gd.uid b/src/scripts/staff_projectile.gd.uid new file mode 100644 index 0000000..48c7ab4 --- /dev/null +++ b/src/scripts/staff_projectile.gd.uid @@ -0,0 +1 @@ +uid://bn5vp502u6pf5 diff --git a/src/shaders/cloth.gdshader b/src/shaders/cloth.gdshader new file mode 100644 index 0000000..8338c19 --- /dev/null +++ b/src/shaders/cloth.gdshader @@ -0,0 +1,60 @@ +shader_type canvas_item; +render_mode unshaded; + +uniform vec4 original_0: source_color; +uniform vec4 original_1: source_color; +uniform vec4 original_2: source_color; +uniform vec4 original_3: source_color; +uniform vec4 original_4: source_color; +uniform vec4 original_5: source_color; +uniform vec4 original_6: source_color; +uniform vec4 replace_0: source_color; +uniform vec4 replace_1: source_color; +uniform vec4 replace_2: source_color; +uniform vec4 replace_3: source_color; +uniform vec4 replace_4: source_color; +uniform vec4 replace_5: source_color; +uniform vec4 replace_6: source_color; + +uniform vec4 tint: source_color = vec4(1.0); + +const float precision = 0.1; +const int Colz = 7; + +vec4 swap_color(vec4 color){ + vec4 original_colors[Colz] = vec4[Colz] (original_0, original_1, original_2, original_3, original_4, original_5, original_6); + vec4 replace_colors[Colz] = vec4[Colz] (replace_0, replace_1, replace_2, replace_3, replace_4, replace_5, replace_6); + for (int i = 0; i < Colz; i ++) { + if (distance(color, original_colors[i]) <= precision){ + return replace_colors[i]; + } + } + return color; +} + + +void fragment() { + vec4 col = swap_color(texture(TEXTURE, UV)); + //#COLOR = mix(col, tint, 1.0); + COLOR = col * tint; +} + + + + + + + + + + + + + + + + + + + + diff --git a/src/shaders/cloth.gdshader.uid b/src/shaders/cloth.gdshader.uid new file mode 100644 index 0000000..b3d17e7 --- /dev/null +++ b/src/shaders/cloth.gdshader.uid @@ -0,0 +1 @@ +uid://ldl7vaq5n13f