added fallout tiles, fixed so all enemys can get affected.

This commit is contained in:
2026-02-03 02:42:51 +01:00
parent 88f51fd8d0
commit 9b8d84357f
19 changed files with 1559 additions and 279 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -297,12 +297,16 @@ separation = Vector2i(1, 1)
9:9/0 = 0 9:9/0 = 0
10:9/0 = 0 10:9/0 = 0
11:9/0 = 0 11:9/0 = 0
11:9/0/custom_data_0 = 1
12:9/0 = 0 12:9/0 = 0
12:9/0/custom_data_0 = 1
13:9/0 = 0 13:9/0 = 0
14:9/0 = 0 14:9/0 = 0
15:9/0 = 0 15:9/0 = 0
16:9/0 = 0 16:9/0 = 0
16:9/0/custom_data_0 = 2
17:9/0 = 0 17:9/0 = 0
17:9/0/custom_data_0 = 2
18:9/0 = 0 18:9/0 = 0
19:9/0 = 0 19:9/0 = 0
0:10/0 = 0 0:10/0 = 0
@@ -321,9 +325,11 @@ separation = Vector2i(1, 1)
11:10/0 = 0 11:10/0 = 0
12:10/0 = 0 12:10/0 = 0
13:10/0 = 0 13:10/0 = 0
13:10/0/custom_data_0 = -1
14:10/0 = 0 14:10/0 = 0
15:10/0 = 0 15:10/0 = 0
16:10/0 = 0 16:10/0 = 0
16:10/0/custom_data_0 = -1
17:10/0 = 0 17:10/0 = 0
1:11/0 = 0 1:11/0 = 0
2:11/0 = 0 2:11/0 = 0
@@ -400,9 +406,11 @@ separation = Vector2i(1, 1)
11:13/0/custom_data_0 = -1 11:13/0/custom_data_0 = -1
12:13/0 = 0 12:13/0 = 0
13:13/0 = 0 13:13/0 = 0
13:13/0/custom_data_0 = -1
14:13/0 = 0 14:13/0 = 0
15:13/0 = 0 15:13/0 = 0
16:13/0 = 0 16:13/0 = 0
16:13/0/custom_data_0 = -1
17:13/0 = 0 17:13/0 = 0
0:14/0 = 0 0:14/0 = 0
0:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667) 0:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667)
@@ -411,13 +419,10 @@ separation = Vector2i(1, 1)
1:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667) 1:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667)
1:14/0/custom_data_0 = 8 1:14/0/custom_data_0 = 8
2:14/0 = 0 2:14/0 = 0
2:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(-8, -8, -2.66667, -8, -2.66667, 8, -8, 8)
2:14/0/custom_data_0 = 8 2:14/0/custom_data_0 = 8
3:14/0 = 0 3:14/0 = 0
3:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(2.66667, -8, 8, -8, 8, 8, 2.66667, 8)
3:14/0/custom_data_0 = 8 3:14/0/custom_data_0 = 8
4:14/0 = 0 4:14/0 = 0
4:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667)
4:14/0/custom_data_0 = 8 4:14/0/custom_data_0 = 8
5:14/0 = 0 5:14/0 = 0
5:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667) 5:14/0/physics_layer_0/polygon_0/points = PackedVector2Array(8, 2.66667, 8, 8, -8, 8, -8, 2.66667)
@@ -465,6 +470,35 @@ separation = Vector2i(1, 1)
14:2/0/physics_layer_0/polygon_0/points = PackedVector2Array(-8, -8, 8, -8, 8, 8, -8, 8) 14:2/0/physics_layer_0/polygon_0/points = PackedVector2Array(-8, -8, 8, -8, 8, 8, -8, 8)
14:1/0 = 0 14:1/0 = 0
14:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-8, -8, 8, -8, 8, 8, -8, 8) 14:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-8, -8, 8, -8, 8, 8, -8, 8)
14:15/0 = 0
15:15/0 = 0
16:15/0 = 0
17:15/0 = 0
18:15/0 = 0
19:15/0 = 0
20:15/0 = 0
20:16/0 = 0
21:16/0 = 0
21:15/0 = 0
16:16/0 = 0
15:16/0 = 0
15:16/0/custom_data_0 = -2
14:16/0 = 0
13:16/0 = 0
12:16/0 = 0
11:16/0 = 0
10:16/0 = 0
9:16/0 = 0
7:15/0 = 0
1:16/0 = 0
0:16/0 = 0
2:16/0 = 0
3:16/0 = 0
4:16/0 = 0
5:16/0 = 0
6:16/0 = 0
7:16/0 = 0
8:16/0 = 0
[resource] [resource]
occlusion_layer_0/light_mask = 1 occlusion_layer_0/light_mask = 1

File diff suppressed because one or more lines are too long

View File

@@ -204,6 +204,24 @@ shader_parameter/replace_5 = Color(0, 0, 0, 1)
shader_parameter/replace_6 = Color(0, 0, 0, 1) shader_parameter/replace_6 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1) shader_parameter/tint = Color(1, 1, 1, 1)
[sub_resource type="ShaderMaterial" id="ShaderMaterial_shield"]
shader = ExtResource("4_r7ul0")
shader_parameter/original_0 = Color(0, 0, 0, 1)
shader_parameter/original_1 = Color(0, 0, 0, 1)
shader_parameter/original_2 = Color(0, 0, 0, 1)
shader_parameter/original_3 = Color(0, 0, 0, 1)
shader_parameter/original_4 = Color(0, 0, 0, 1)
shader_parameter/original_5 = Color(0, 0, 0, 1)
shader_parameter/original_6 = Color(0, 0, 0, 1)
shader_parameter/replace_0 = Color(0, 0, 0, 1)
shader_parameter/replace_1 = Color(0, 0, 0, 1)
shader_parameter/replace_2 = Color(0, 0, 0, 1)
shader_parameter/replace_3 = Color(0, 0, 0, 1)
shader_parameter/replace_4 = Color(0, 0, 0, 1)
shader_parameter/replace_5 = Color(0, 0, 0, 1)
shader_parameter/replace_6 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1)
[sub_resource type="CircleShape2D" id="CircleShape2D_1"] [sub_resource type="CircleShape2D" id="CircleShape2D_1"]
radius = 5.0 radius = 5.0
@@ -227,23 +245,13 @@ stream_2/stream = ExtResource("11_5x2ph")
stream_3/stream = ExtResource("12_oynfq") stream_3/stream = ExtResource("12_oynfq")
stream_4/stream = ExtResource("13_b0veo") stream_4/stream = ExtResource("13_b0veo")
[sub_resource type="ShaderMaterial" id="ShaderMaterial_shield"] [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_block"]
shader = ExtResource("4_r7ul0") playback_mode = 1
shader_parameter/original_0 = Color(0, 0, 0, 1) streams_count = 4
shader_parameter/original_1 = Color(0, 0, 0, 1) stream_0/stream = ExtResource("18_sfx")
shader_parameter/original_2 = Color(0, 0, 0, 1) stream_1/stream = ExtResource("19_sfx")
shader_parameter/original_3 = Color(0, 0, 0, 1) stream_2/stream = ExtResource("20_sfx")
shader_parameter/original_4 = Color(0, 0, 0, 1) stream_3/stream = ExtResource("21_sfx")
shader_parameter/original_5 = Color(0, 0, 0, 1)
shader_parameter/original_6 = Color(0, 0, 0, 1)
shader_parameter/replace_0 = Color(0, 0, 0, 1)
shader_parameter/replace_1 = Color(0, 0, 0, 1)
shader_parameter/replace_2 = Color(0, 0, 0, 1)
shader_parameter/replace_3 = Color(0, 0, 0, 1)
shader_parameter/replace_4 = Color(0, 0, 0, 1)
shader_parameter/replace_5 = Color(0, 0, 0, 1)
shader_parameter/replace_6 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1)
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_bow"] [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_bow"]
playback_mode = 1 playback_mode = 1
@@ -253,14 +261,6 @@ stream_0/stream = ExtResource("22_sfx")
stream_1/stream = ExtResource("23_sfx") stream_1/stream = ExtResource("23_sfx")
stream_2/stream = ExtResource("24_sfx") stream_2/stream = ExtResource("24_sfx")
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_block"]
playback_mode = 1
streams_count = 4
stream_0/stream = ExtResource("18_sfx")
stream_1/stream = ExtResource("19_sfx")
stream_2/stream = ExtResource("20_sfx")
stream_3/stream = ExtResource("21_sfx")
[node name="EnemyHumanoid" type="CharacterBody2D" unique_id=285357386] [node name="EnemyHumanoid" type="CharacterBody2D" unique_id=285357386]
collision_layer = 2 collision_layer = 2
collision_mask = 65 collision_mask = 65
@@ -269,13 +269,14 @@ script = ExtResource("1")
[node name="Shadow" type="Sprite2D" parent="." unique_id=468462304] [node name="Shadow" type="Sprite2D" parent="." unique_id=468462304]
z_index = -1 z_index = -1
position = Vector2(0, 7) position = Vector2(0, 3)
texture = SubResource("GradientTexture2D_1") texture = SubResource("GradientTexture2D_1")
script = ExtResource("2") script = ExtResource("2")
[node name="Sprite2DBody" type="Sprite2D" parent="." unique_id=855871821] [node name="Sprite2DBody" type="Sprite2D" parent="." unique_id=855871821]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_uedn7") material = SubResource("ShaderMaterial_uedn7")
position = Vector2(0, -4)
texture = ExtResource("3") texture = ExtResource("3")
hframes = 35 hframes = 35
vframes = 8 vframes = 8
@@ -283,59 +284,67 @@ vframes = 8
[node name="Sprite2DBoots" type="Sprite2D" parent="." unique_id=460958943] [node name="Sprite2DBoots" type="Sprite2D" parent="." unique_id=460958943]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_5x2ph") material = SubResource("ShaderMaterial_5x2ph")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DArmour" type="Sprite2D" parent="." unique_id=6790482] [node name="Sprite2DArmour" type="Sprite2D" parent="." unique_id=6790482]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_r7ul0") material = SubResource("ShaderMaterial_r7ul0")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DFacialHair" type="Sprite2D" parent="." unique_id=31110906] [node name="Sprite2DFacialHair" type="Sprite2D" parent="." unique_id=31110906]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_oynfq") material = SubResource("ShaderMaterial_oynfq")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DHair" type="Sprite2D" parent="." unique_id=425592986] [node name="Sprite2DHair" type="Sprite2D" parent="." unique_id=425592986]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_b0veo") material = SubResource("ShaderMaterial_b0veo")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DEyes" type="Sprite2D" parent="." unique_id=496437887] [node name="Sprite2DEyes" type="Sprite2D" parent="." unique_id=496437887]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_of8l8") material = SubResource("ShaderMaterial_of8l8")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DEyeLashes" type="Sprite2D" parent="." unique_id=1799398723] [node name="Sprite2DEyeLashes" type="Sprite2D" parent="." unique_id=1799398723]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_ofeay") material = SubResource("ShaderMaterial_ofeay")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DAddons" type="Sprite2D" parent="." unique_id=1702763725] [node name="Sprite2DAddons" type="Sprite2D" parent="." unique_id=1702763725]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_5a33a") material = SubResource("ShaderMaterial_5a33a")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DHeadgear" type="Sprite2D" parent="." unique_id=164186416] [node name="Sprite2DHeadgear" type="Sprite2D" parent="." unique_id=164186416]
y_sort_enabled = true y_sort_enabled = true
material = SubResource("ShaderMaterial_i1636") material = SubResource("ShaderMaterial_i1636")
position = Vector2(0, -4)
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DShield" type="Sprite2D" parent="."] [node name="Sprite2DShield" type="Sprite2D" parent="." unique_id=470468744]
visible = false visible = false
material = SubResource("ShaderMaterial_shield") material = SubResource("ShaderMaterial_shield")
texture = ExtResource("14_shield") texture = ExtResource("14_shield")
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Sprite2DShieldHolding" type="Sprite2D" parent="."] [node name="Sprite2DShieldHolding" type="Sprite2D" parent="." unique_id=1318098286]
visible = false visible = false
material = SubResource("ShaderMaterial_shield") material = SubResource("ShaderMaterial_shield")
texture = ExtResource("15_shieldh") texture = ExtResource("15_shieldh")
@@ -344,11 +353,13 @@ vframes = 8
[node name="Sprite2DWeapon" type="Sprite2D" parent="." unique_id=1718282928] [node name="Sprite2DWeapon" type="Sprite2D" parent="." unique_id=1718282928]
y_sort_enabled = true y_sort_enabled = true
position = Vector2(0, -4)
texture = ExtResource("4") texture = ExtResource("4")
hframes = 35 hframes = 35
vframes = 8 vframes = 8
[node name="Incantation" parent="." instance=ExtResource("16_inc")] [node name="Incantation" parent="." unique_id=441417699 instance=ExtResource("16_inc")]
position = Vector2(0, -4)
[node name="AlertIndicator" type="Sprite2D" parent="." unique_id=1697001148] [node name="AlertIndicator" type="Sprite2D" parent="." unique_id=1697001148]
visible = false visible = false
@@ -365,10 +376,10 @@ texture = ExtResource("6")
hframes = 3 hframes = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=189217716] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=189217716]
position = Vector2(0, 4)
shape = SubResource("CircleShape2D_1") shape = SubResource("CircleShape2D_1")
[node name="AttackArea" type="Area2D" parent="." unique_id=1923132385] [node name="AttackArea" type="Area2D" parent="." unique_id=1923132385]
position = Vector2(0, -4)
collision_layer = 0 collision_layer = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="AttackArea" unique_id=1597070641] [node name="CollisionShape2D" type="CollisionShape2D" parent="AttackArea" unique_id=1597070641]
@@ -376,12 +387,14 @@ position = Vector2(0, 4)
shape = SubResource("CircleShape2D_1") shape = SubResource("CircleShape2D_1")
[node name="AggroArea" type="Area2D" parent="." unique_id=1234567890] [node name="AggroArea" type="Area2D" parent="." unique_id=1234567890]
position = Vector2(0, -4)
collision_layer = 0 collision_layer = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="AggroArea" unique_id=1286608618] [node name="CollisionShape2D" type="CollisionShape2D" parent="AggroArea" unique_id=1286608618]
shape = SubResource("CircleShape2D_2") shape = SubResource("CircleShape2D_2")
[node name="SfxDie" type="AudioStreamPlayer2D" parent="." unique_id=693933783] [node name="SfxDie" type="AudioStreamPlayer2D" parent="." unique_id=693933783]
position = Vector2(0, -4)
stream = SubResource("AudioStreamRandomizer_fikv0") stream = SubResource("AudioStreamRandomizer_fikv0")
max_distance = 930.0 max_distance = 930.0
attenuation = 8.282114 attenuation = 8.282114
@@ -390,6 +403,7 @@ panning_strength = 1.3
bus = &"Sfx" bus = &"Sfx"
[node name="SfxAlertFoundPlayer" type="AudioStreamPlayer2D" parent="." unique_id=815591859] [node name="SfxAlertFoundPlayer" type="AudioStreamPlayer2D" parent="." unique_id=815591859]
position = Vector2(0, -4)
stream = SubResource("AudioStreamRandomizer_37mja") stream = SubResource("AudioStreamRandomizer_37mja")
max_distance = 1146.0 max_distance = 1146.0
attenuation = 8.57418 attenuation = 8.57418
@@ -397,45 +411,53 @@ max_polyphony = 4
panning_strength = 1.04 panning_strength = 1.04
bus = &"Sfx" bus = &"Sfx"
[node name="SfxActivateShield" type="AudioStreamPlayer2D" parent="."] [node name="SfxActivateShield" type="AudioStreamPlayer2D" parent="." unique_id=626374525]
position = Vector2(0, -4)
stream = ExtResource("17_sfx") stream = ExtResource("17_sfx")
volume_db = 9.695 volume_db = 9.695
attenuation = 1.3660401 attenuation = 1.3660401
panning_strength = 1.78 panning_strength = 1.78
[node name="SfxBlockWithShield" type="AudioStreamPlayer2D" parent="."] [node name="SfxBlockWithShield" type="AudioStreamPlayer2D" parent="." unique_id=4928520]
position = Vector2(0, -4)
stream = SubResource("AudioStreamRandomizer_block") stream = SubResource("AudioStreamRandomizer_block")
volume_db = 7.254 volume_db = 7.254
attenuation = 1.3195078 attenuation = 1.3195078
panning_strength = 1.06 panning_strength = 1.06
bus = &"Sfx" bus = &"Sfx"
[node name="SfxBowShoot" type="AudioStreamPlayer2D" parent="."] [node name="SfxBowShoot" type="AudioStreamPlayer2D" parent="." unique_id=495272024]
position = Vector2(0, -4)
stream = SubResource("AudioStreamRandomizer_bow") stream = SubResource("AudioStreamRandomizer_bow")
pitch_scale = 1.33 pitch_scale = 1.33
attenuation = 6.7271657 attenuation = 6.7271657
[node name="SfxBowWithoutArrow" type="AudioStreamPlayer2D" parent="."] [node name="SfxBowWithoutArrow" type="AudioStreamPlayer2D" parent="." unique_id=343560488]
position = Vector2(0, -4)
stream = ExtResource("25_sfx") stream = ExtResource("25_sfx")
max_distance = 1455.0 max_distance = 1455.0
attenuation = 7.4642572 attenuation = 7.4642572
[node name="SfxBuckleBow" type="AudioStreamPlayer2D" parent="."] [node name="SfxBuckleBow" type="AudioStreamPlayer2D" parent="." unique_id=1114353313]
position = Vector2(0, -4)
stream = ExtResource("26_sfx") stream = ExtResource("26_sfx")
attenuation = 7.727478 attenuation = 7.727478
panning_strength = 1.03 panning_strength = 1.03
[node name="SfxSpellCharge" type="AudioStreamPlayer2D" parent="."] [node name="SfxSpellCharge" type="AudioStreamPlayer2D" parent="." unique_id=671028964]
position = Vector2(0, -4)
stream = ExtResource("27_sfx") stream = ExtResource("27_sfx")
[node name="SfxThrow" type="AudioStreamPlayer2D" parent="."] [node name="SfxThrow" type="AudioStreamPlayer2D" parent="." unique_id=92763809]
position = Vector2(0, -4)
stream = ExtResource("28_sfx") stream = ExtResource("28_sfx")
pitch_scale = 0.61 pitch_scale = 0.61
max_distance = 983.0 max_distance = 983.0
attenuation = 8.876549 attenuation = 8.876549
panning_strength = 1.04 panning_strength = 1.04
[node name="SfxLift" type="AudioStreamPlayer2D" parent="."] [node name="SfxLift" type="AudioStreamPlayer2D" parent="." unique_id=722569138]
position = Vector2(0, -4)
stream = ExtResource("29_sfx") stream = ExtResource("29_sfx")
max_distance = 1246.0 max_distance = 1246.0
attenuation = 1.9999994 attenuation = 1.9999994

3
src/scenes/fallout.tscn Normal file
View File

@@ -0,0 +1,3 @@
[gd_scene format=3 uid="uid://cm4f7w8ohdi7r"]
[node name="Fallout" type="Node2D" unique_id=587574934]

View File

@@ -42,6 +42,105 @@ shader_parameter/replace_13 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1) shader_parameter/tint = Color(1, 1, 1, 1)
shader_parameter/ambient = Color(1, 1, 1, 1) shader_parameter/ambient = Color(1, 1, 1, 1)
[sub_resource type="ShaderMaterial" id="ShaderMaterial_u1jpj"]
shader = ExtResource("4_bhwwd")
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/original_7 = Color(0, 0, 0, 1)
shader_parameter/original_8 = Color(0, 0, 0, 1)
shader_parameter/original_9 = Color(0, 0, 0, 1)
shader_parameter/original_10 = Color(0, 0, 0, 1)
shader_parameter/original_11 = Color(0, 0, 0, 1)
shader_parameter/original_12 = Color(0, 0, 0, 1)
shader_parameter/original_13 = 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/replace_7 = Color(0, 0, 0, 1)
shader_parameter/replace_8 = Color(0, 0, 0, 1)
shader_parameter/replace_9 = Color(0, 0, 0, 1)
shader_parameter/replace_10 = Color(0, 0, 0, 1)
shader_parameter/replace_11 = Color(0, 0, 0, 1)
shader_parameter/replace_12 = Color(0, 0, 0, 1)
shader_parameter/replace_13 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1)
shader_parameter/ambient = Color(1, 1, 1, 1)
[sub_resource type="ShaderMaterial" id="ShaderMaterial_uh34q"]
shader = ExtResource("4_bhwwd")
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/original_7 = Color(0, 0, 0, 1)
shader_parameter/original_8 = Color(0, 0, 0, 1)
shader_parameter/original_9 = Color(0, 0, 0, 1)
shader_parameter/original_10 = Color(0, 0, 0, 1)
shader_parameter/original_11 = Color(0, 0, 0, 1)
shader_parameter/original_12 = Color(0, 0, 0, 1)
shader_parameter/original_13 = 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/replace_7 = Color(0, 0, 0, 1)
shader_parameter/replace_8 = Color(0, 0, 0, 1)
shader_parameter/replace_9 = Color(0, 0, 0, 1)
shader_parameter/replace_10 = Color(0, 0, 0, 1)
shader_parameter/replace_11 = Color(0, 0, 0, 1)
shader_parameter/replace_12 = Color(0, 0, 0, 1)
shader_parameter/replace_13 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1)
shader_parameter/ambient = Color(1, 1, 1, 1)
[sub_resource type="ShaderMaterial" id="ShaderMaterial_ph1f2"]
shader = ExtResource("4_bhwwd")
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/original_7 = Color(0, 0, 0, 1)
shader_parameter/original_8 = Color(0, 0, 0, 1)
shader_parameter/original_9 = Color(0, 0, 0, 1)
shader_parameter/original_10 = Color(0, 0, 0, 1)
shader_parameter/original_11 = Color(0, 0, 0, 1)
shader_parameter/original_12 = Color(0, 0, 0, 1)
shader_parameter/original_13 = 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/replace_7 = Color(0, 0, 0, 1)
shader_parameter/replace_8 = Color(0, 0, 0, 1)
shader_parameter/replace_9 = Color(0, 0, 0, 1)
shader_parameter/replace_10 = Color(0, 0, 0, 1)
shader_parameter/replace_11 = Color(0, 0, 0, 1)
shader_parameter/replace_12 = Color(0, 0, 0, 1)
shader_parameter/replace_13 = Color(0, 0, 0, 1)
shader_parameter/tint = Color(1, 1, 1, 1)
shader_parameter/ambient = Color(1, 1, 1, 1)
[sub_resource type="ShaderMaterial" id="ShaderMaterial_bhwwd"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_bhwwd"]
shader = ExtResource("4_bhwwd") shader = ExtResource("4_bhwwd")
shader_parameter/original_0 = Color(0, 0, 0, 1) shader_parameter/original_0 = Color(0, 0, 0, 1)
@@ -92,7 +191,16 @@ z_index = -2
material = SubResource("ShaderMaterial_pdbwf") material = SubResource("ShaderMaterial_pdbwf")
tile_set = ExtResource("9") tile_set = ExtResource("9")
[node name="TileMapLayerDecoratedGround" type="TileMapLayer" parent="Environment" unique_id=1839647666]
material = SubResource("ShaderMaterial_u1jpj")
tile_set = ExtResource("9")
[node name="TileMapLayerCrackedGround" type="TileMapLayer" parent="Environment" unique_id=556112467]
material = SubResource("ShaderMaterial_uh34q")
tile_set = ExtResource("9")
[node name="TileMapLayerMiddle" type="TileMapLayer" parent="Environment" unique_id=1063124770] [node name="TileMapLayerMiddle" type="TileMapLayer" parent="Environment" unique_id=1063124770]
material = SubResource("ShaderMaterial_ph1f2")
tile_set = ExtResource("9") tile_set = ExtResource("9")
[node name="TileMapLayerAbove" type="TileMapLayer" parent="Environment" unique_id=1234567892] [node name="TileMapLayerAbove" type="TileMapLayer" parent="Environment" unique_id=1234567892]

View File

@@ -289,6 +289,9 @@ radius = 4.0
[sub_resource type="CircleShape2D" id="CircleShape2D_2"] [sub_resource type="CircleShape2D" id="CircleShape2D_2"]
radius = 8.0 radius = 8.0
[sub_resource type="RectangleShape2D" id="RectangleShape2D_quicksand"]
size = Vector2(8, 8)
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_l71n6"] [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_l71n6"]
playback_mode = 1 playback_mode = 1
random_pitch = 1.0118532 random_pitch = 1.0118532
@@ -340,6 +343,21 @@ tracks/0/keys = {
"values": [2037] "values": [2037]
} }
[sub_resource type="Animation" id="Animation_6e8lb"]
resource_name = "fallout"
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(-0.029999994),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [2037]
}
[sub_resource type="Animation" id="Animation_j2b1d"] [sub_resource type="Animation" id="Animation_j2b1d"]
resource_name = "fire_charging" resource_name = "fire_charging"
length = 0.4 length = 0.4
@@ -459,6 +477,7 @@ tracks/0/keys = {
[sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"] [sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"]
_data = { _data = {
&"RESET": SubResource("Animation_t4otl"), &"RESET": SubResource("Animation_t4otl"),
&"fallout": SubResource("Animation_6e8lb"),
&"fire_charging": SubResource("Animation_j2b1d"), &"fire_charging": SubResource("Animation_j2b1d"),
&"fire_ready": SubResource("Animation_cs1tg"), &"fire_ready": SubResource("Animation_cs1tg"),
&"frost_charging": SubResource("Animation_frost_ch"), &"frost_charging": SubResource("Animation_frost_ch"),
@@ -702,11 +721,20 @@ collision_mask = 3
shape = SubResource("CircleShape2D_2") shape = SubResource("CircleShape2D_2")
debug_color = Color(0.70196074, 0.6126261, 0.19635464, 0.41960785) debug_color = Color(0.70196074, 0.6126261, 0.19635464, 0.41960785)
[node name="QuicksandArea" type="Area2D" parent="." unique_id=600000001]
position = Vector2(0, 4)
collision_layer = 0
collision_mask = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="QuicksandArea" unique_id=600000002]
shape = SubResource("RectangleShape2D_quicksand")
debug_color = Color(0.48027503, 0, 0.70196074, 0.41960785)
[node name="Label" type="Label" parent="." unique_id=227628720] [node name="Label" type="Label" parent="." unique_id=227628720]
offset_left = -10.0 offset_left = -10.0
offset_top = -15.0 offset_top = -15.0
offset_right = 10.0 offset_right = 10.0
offset_bottom = -9.0 offset_bottom = 8.0
horizontal_alignment = 1 horizontal_alignment = 1
[node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470] [node name="InteractionIndicator" type="Sprite2D" parent="." unique_id=1661043470]
@@ -732,6 +760,12 @@ volume_db = -2.537
attenuation = 8.876548 attenuation = 8.876548
bus = &"Sfx" bus = &"Sfx"
[node name="SfxFallout" type="AudioStreamPlayer2D" parent="." unique_id=600000003]
stream = ExtResource("19_4r5pv")
volume_db = -2.537
attenuation = 8.876548
bus = &"Sfx"
[node name="SfxTakeDamage" type="AudioStreamPlayer2D" parent="." unique_id=322150091] [node name="SfxTakeDamage" type="AudioStreamPlayer2D" parent="." unique_id=322150091]
stream = SubResource("AudioStreamRandomizer_487ah") stream = SubResource("AudioStreamRandomizer_487ah")
volume_db = -6.092 volume_db = -6.092

View File

@@ -2,15 +2,15 @@ extends CharacterBody2D
# Bomb - Explosive projectile that can be thrown or placed # Bomb - Explosive projectile that can be thrown or placed
@export var fuse_duration: float = 3.0 # Time until explosion @export var fuse_duration: float = 3.0 # Time until explosion
@export var base_damage: float = 50.0 # Base damage (increased from 30) @export var base_damage: float = 50.0 # Base damage (increased from 30)
@export var damage_radius: float = 48.0 # Area of effect radius (48x48) @export var damage_radius: float = 48.0 # Area of effect radius (48x48)
@export var screenshake_strength: float = 18.0 # Base screenshake strength (stronger) @export var screenshake_strength: float = 18.0 # Base screenshake strength (stronger)
var player_owner: Node = null var player_owner: Node = null
var is_fused: bool = false var is_fused: bool = false
var fuse_timer: float = 0.0 var fuse_timer: float = 0.0
var is_thrown: bool = false # True if thrown by Dwarf, false if placed var is_thrown: bool = false # True if thrown by Dwarf, false if placed
var is_exploding: bool = false var is_exploding: bool = false
var explosion_frame: int = 0 var explosion_frame: int = 0
var explosion_timer: float = 0.0 var explosion_timer: float = 0.0
@@ -21,16 +21,21 @@ var velocity_z: float = 0.0
var gravity_z: float = 500.0 var gravity_z: float = 500.0
var is_airborne: bool = false var is_airborne: bool = false
var throw_velocity: Vector2 = Vector2.ZERO var throw_velocity: Vector2 = Vector2.ZERO
var rotation_speed: float = 0.0 # Angular velocity when thrown var rotation_speed: float = 0.0 # Angular velocity when thrown
# Blinking animation # Blinking animation
var blink_timer: float = 0.0 var blink_timer: float = 0.0
var bomb_visible: bool = true # Renamed to avoid shadowing CanvasItem.is_visible var bomb_visible: bool = true # Renamed to avoid shadowing CanvasItem.is_visible
var blink_start_time: float = 1.0 # Start blinking 1 second before explosion var blink_start_time: float = 1.0 # Start blinking 1 second before explosion
# Collection # Collection
var can_be_collected: bool = false var can_be_collected: bool = false
var collection_delay: float = 0.2 # Can be collected after 0.2 seconds var collection_delay: float = 0.2 # Can be collected after 0.2 seconds
# Fallout (landed in quicksand): sink and explode with no visual/damage, but sound + screenshake
var fell_in_fallout: bool = false
var fallout_sink_progress: float = 1.0
const FALLOUT_SINK_DURATION: float = 0.5
@onready var sprite = $Sprite2D @onready var sprite = $Sprite2D
@onready var explosion_sprite = $ExplosionSprite @onready var explosion_sprite = $ExplosionSprite
@@ -45,13 +50,13 @@ var collection_delay: float = 0.2 # Can be collected after 0.2 seconds
var damage_area_shape: CircleShape2D = null var damage_area_shape: CircleShape2D = null
const TILE_SIZE: int = 16 const TILE_SIZE: int = 16
const TILE_STRIDE: int = 17 # 16 + separation 1 const TILE_STRIDE: int = 17 # 16 + separation 1
var _explosion_tile_particle_scene: PackedScene = null var _explosion_tile_particle_scene: PackedScene = null
func _ready(): func _ready():
# Set collision layer to 2 (interactable objects) so it can be grabbed # Set collision layer to 2 (interactable objects) so it can be grabbed
collision_layer = 2 collision_layer = 2
collision_mask = 1 | 2 | 64 # Collide with players, objects, and walls collision_mask = 1 | 2 | 64 # Collide with players, objects, and walls
# Connect area signals # Connect area signals
if bomb_area and not bomb_area.body_entered.is_connected(_on_bomb_area_body_entered): if bomb_area and not bomb_area.body_entered.is_connected(_on_bomb_area_body_entered):
@@ -85,7 +90,7 @@ func _deferred_ready():
var collision_shape = bomb_area.get_node_or_null("CollisionShape2D") var collision_shape = bomb_area.get_node_or_null("CollisionShape2D")
if collision_shape: if collision_shape:
damage_area_shape = CircleShape2D.new() damage_area_shape = CircleShape2D.new()
damage_area_shape.radius = damage_radius / 2.0 # 24 pixel radius for 48x48 damage_area_shape.radius = damage_radius / 2.0 # 24 pixel radius for 48x48
collision_shape.shape = damage_area_shape collision_shape.shape = damage_area_shape
# Start fuse if not thrown (placed bomb starts fusing immediately; thrown bombs start fuse on land) # Start fuse if not thrown (placed bomb starts fusing immediately; thrown bombs start fuse on land)
@@ -143,11 +148,14 @@ func _start_fuse():
func _physics_process(delta): func _physics_process(delta):
if is_exploding: if is_exploding:
# Handle explosion animation # Handle explosion animation (or immediate free if fell in fallout)
if fell_in_fallout:
queue_free()
return
explosion_timer += delta explosion_timer += delta
if explosion_sprite: if explosion_sprite:
# Play 9 frames of explosion animation at ~15 FPS # Play 9 frames of explosion animation at ~15 FPS
if explosion_timer >= 0.06666667: # ~15 FPS if explosion_timer >= 0.06666667: # ~15 FPS
explosion_timer = 0.0 explosion_timer = 0.0
explosion_frame += 1 explosion_frame += 1
if explosion_frame < 9: if explosion_frame < 9:
@@ -157,6 +165,21 @@ func _physics_process(delta):
queue_free() queue_free()
return return
# Fallout sink: scale down over time (no respawn)
if fell_in_fallout:
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
if fallout_sink_progress < 0.0:
fallout_sink_progress = 0.0
scale = Vector2.ONE * max(0.0, fallout_sink_progress)
# Fuse still runs; when it hits, _explode() will do sound + screenshake only
if is_fused:
fuse_timer += delta
if fuse_timer >= fuse_duration:
_explode()
return
move_and_slide()
return
# Update fuse timer # Update fuse timer
if is_fused: if is_fused:
fuse_timer += delta fuse_timer += delta
@@ -164,7 +187,7 @@ func _physics_process(delta):
# Start blinking when close to explosion # Start blinking when close to explosion
if fuse_timer >= (fuse_duration - blink_start_time): if fuse_timer >= (fuse_duration - blink_start_time):
blink_timer += delta blink_timer += delta
if blink_timer >= 0.1: # Blink every 0.1 seconds if blink_timer >= 0.1: # Blink every 0.1 seconds
blink_timer = 0.0 blink_timer = 0.0
bomb_visible = not bomb_visible bomb_visible = not bomb_visible
if sprite: if sprite:
@@ -183,7 +206,7 @@ func _physics_process(delta):
# Update sprite position and rotation based on height # Update sprite position and rotation based on height
if sprite: if sprite:
sprite.position.y = -position_z * 0.5 sprite.position.y = - position_z * 0.5
var height_scale = 1.0 - (position_z / 50.0) * 0.2 var height_scale = 1.0 - (position_z / 50.0) * 0.2
sprite.scale = Vector2.ONE * max(0.8, height_scale) sprite.scale = Vector2.ONE * max(0.8, height_scale)
sprite.rotation += rotation_speed * delta sprite.rotation += rotation_speed * delta
@@ -246,6 +269,15 @@ func _land():
position_z = 0.0 position_z = 0.0
velocity_z = 0.0 velocity_z = 0.0
# If landed on fallout tile: sink and will explode with no visual/damage (sound + screenshake only)
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
fell_in_fallout = true
fallout_sink_progress = 1.0
can_be_collected = false
if collection_area:
collection_area.set_deferred("monitoring", false)
# Start fuse when landing # Start fuse when landing
if not is_fused: if not is_fused:
_start_fuse() _start_fuse()
@@ -256,7 +288,27 @@ func _explode():
is_exploding = true is_exploding = true
# Hide bomb sprite and shadow, show explosion # Stop fuse sound and particles
if has_node("SfxFuse"):
$SfxFuse.stop()
if fuse_particles:
fuse_particles.emitting = false
if fuse_light:
fuse_light.enabled = false
# Fell in fallout: no explosion visual, no damage, but sound + screenshake
if fell_in_fallout:
if has_node("SfxExplosion"):
$SfxExplosion.play()
_cause_screenshake()
if bomb_area:
bomb_area.set_deferred("monitoring", false)
if collection_area:
collection_area.set_deferred("monitoring", false)
queue_free()
return
# Normal explosion
if sprite: if sprite:
sprite.visible = false sprite.visible = false
if shadow: if shadow:
@@ -267,38 +319,27 @@ func _explode():
explosion_frame = 0 explosion_frame = 0
explosion_timer = 0.0 explosion_timer = 0.0
# Stop fuse sound and particles
if has_node("SfxFuse"):
$SfxFuse.stop()
if fuse_particles:
fuse_particles.emitting = false
# Disable fuse light, enable explosion light
if fuse_light:
fuse_light.enabled = false
if explosion_light: if explosion_light:
explosion_light.enabled = true explosion_light.enabled = true
# Fade out explosion light over time
var tween = create_tween() var tween = create_tween()
tween.tween_property(explosion_light, "energy", 0.0, 0.3) tween.tween_property(explosion_light, "energy", 0.0, 0.3)
tween.tween_callback(func(): if explosion_light: explosion_light.enabled = false) tween.tween_callback(func(): if explosion_light: explosion_light.enabled = false)
# Play explosion sound
if has_node("SfxExplosion"): if has_node("SfxExplosion"):
$SfxExplosion.play() $SfxExplosion.play()
# Deal area damage
_deal_explosion_damage() _deal_explosion_damage()
# Cause screenshake var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("break_cracked_tiles_in_radius"):
gw.break_cracked_tiles_in_radius(global_position, damage_radius / 2.0)
_cause_screenshake() _cause_screenshake()
# Spawn tile debris particles (4 pieces per affected tile, bounce, fade)
_spawn_explosion_tile_particles() _spawn_explosion_tile_particles()
if has_node("SfxDebrisFromParticles"): if has_node("SfxDebrisFromParticles"):
$SfxDebrisFromParticles.play() $SfxDebrisFromParticles.play()
# Disable collision
if bomb_area: if bomb_area:
bomb_area.set_deferred("monitoring", false) bomb_area.set_deferred("monitoring", false)
if collection_area: if collection_area:
@@ -333,8 +374,8 @@ func _deal_explosion_damage():
# Calculate distance for damage falloff # Calculate distance for damage falloff
var distance = global_position.distance_to(body.global_position) var distance = global_position.distance_to(body.global_position)
var damage_multiplier = 1.0 - (distance / damage_radius) # Linear falloff var damage_multiplier = 1.0 - (distance / damage_radius) # Linear falloff
damage_multiplier = max(0.1, damage_multiplier) # Minimum 10% damage damage_multiplier = max(0.1, damage_multiplier) # Minimum 10% damage
var final_damage = total_damage * damage_multiplier var final_damage = total_damage * damage_multiplier
# Deal damage to players # Deal damage to players
@@ -414,7 +455,7 @@ func _spawn_explosion_tile_particles():
continue continue
var bx = atlas.x * TILE_STRIDE var bx = atlas.x * TILE_STRIDE
var by = atlas.y * TILE_STRIDE var by = atlas.y * TILE_STRIDE
var h = 8.0 # TILE_SIZE / 2 var h = 8.0 # TILE_SIZE / 2
var regions = [ var regions = [
Rect2(bx, by, h, h), Rect2(bx, by, h, h),
Rect2(bx + h, by, h, h), Rect2(bx + h, by, h, h),
@@ -444,12 +485,12 @@ func _spawn_explosion_tile_particles():
spr.material = particle_material spr.material = particle_material
p.global_position = world p.global_position = world
var speed = randf_range(280.0, 420.0) # Much faster - fly around more var speed = randf_range(280.0, 420.0) # Much faster - fly around more
var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
p.velocity = d.normalized() * speed p.velocity = d.normalized() * speed
p.angular_velocity = randf_range(-14.0, 14.0) p.angular_velocity = randf_range(-14.0, 14.0)
p.position_z = 0.0 p.position_z = 0.0
p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down
parent.add_child(p) parent.add_child(p)
func _cause_screenshake(): func _cause_screenshake():
@@ -477,11 +518,11 @@ func _cause_screenshake():
# Calculate screenshake strength (inverse distance, capped) # Calculate screenshake strength (inverse distance, capped)
var shake_strength = screenshake_strength / max(1.0, min_distance / 50.0) var shake_strength = screenshake_strength / max(1.0, min_distance / 50.0)
shake_strength = min(shake_strength, screenshake_strength * 2.0) # Cap at 2x base shake_strength = min(shake_strength, screenshake_strength * 2.0) # Cap at 2x base
# Apply screenshake (longer duration for bigger boom) # Apply screenshake (longer duration for bigger boom)
if game_world.has_method("add_screenshake"): if game_world.has_method("add_screenshake"):
game_world.add_screenshake(shake_strength, 0.5) # 0.5 second duration game_world.add_screenshake(shake_strength, 0.5) # 0.5 second duration
func _on_bomb_area_body_entered(_body): func _on_bomb_area_body_entered(_body):
# This is for explosion damage (handled in _deal_explosion_damage) # This is for explosion damage (handled in _deal_explosion_damage)
@@ -540,7 +581,7 @@ func on_grabbed(by_player):
if parent: if parent:
parent.add_child(ft) parent.add_child(ft)
ft.global_position = Vector2(by_player.global_position.x, by_player.global_position.y - 20) ft.global_position = Vector2(by_player.global_position.x, by_player.global_position.y - 20)
ft.setup("+1 Bomb", Color(0.9, 0.5, 0.2), 0.5, 0.5) # Orange-ish ft.setup("+1 Bomb", Color(0.9, 0.5, 0.2), 0.5, 0.5) # Orange-ish
# Play pickup sound # Play pickup sound
if has_node("SfxPickup"): if has_node("SfxPickup"):

View File

@@ -53,12 +53,6 @@ const WALL_BOTTOM_RIGHT_BOTTOM_RIGHT = Vector2i(4, 4) # Bottom-right corner, bot
const WALL_BOTTOM_UPPER = Vector2i(2, 3) # Bottom wall, upper part const WALL_BOTTOM_UPPER = Vector2i(2, 3) # Bottom wall, upper part
const WALL_BOTTOM_LOWER = Vector2i(2, 4) # Bottom wall, lower part const WALL_BOTTOM_LOWER = Vector2i(2, 4) # Bottom wall, lower part
# Inner wall tiles (for non-rectangular rooms)
const INNER_WALL_TOP_LEFT = Vector2i(1, 6)
const INNER_WALL_TOP_RIGHT = Vector2i(3, 6)
const INNER_WALL_BOTTOM_LEFT = Vector2i(1, 8)
const INNER_WALL_BOTTOM_RIGHT = Vector2i(3, 8)
# Door tiles # Door tiles
const DOOR_UP_START = Vector2i(7, 0) # 3x2 large const DOOR_UP_START = Vector2i(7, 0) # 3x2 large
const DOOR_LEFT_START = Vector2i(5, 2) # 2x3 large const DOOR_LEFT_START = Vector2i(5, 2) # 2x3 large
@@ -71,8 +65,35 @@ const STAIRS_LEFT_START = Vector2i(5, 2) # 2x3 large, middle tile is (5,1) inste
const STAIRS_RIGHT_START = Vector2i(10, 2) # 2x3 large, middle tile is (11,1) instead of (11,3) const STAIRS_RIGHT_START = Vector2i(10, 2) # 2x3 large, middle tile is (11,1) instead of (11,3)
const STAIRS_DOWN_START = Vector2i(7, 5) # 3x2 large, middle tile is (6,6) instead of (8,6) const STAIRS_DOWN_START = Vector2i(7, 5) # 3x2 large, middle tile is (6,6) instead of (8,6)
# Ground/floor tiles (random selection) # Base floor tile always on DungeonLayer0 (standard floor)
const FLOOR_TILES = [Vector2i(9, 8), Vector2i(14, 8), Vector2i(6, 11)] const FLOOR_BASE = Vector2i(0, 15)
# Decorated ground tiles (TileMapLayerDecoratedGround): (16,16) = most common (blank), then (1,15)-(21,15), (0,16)-(14,16)
const FLOOR_DECORATED_PLAIN = Vector2i(16, 16) # Blank/non-decorated (0,0 has collision)
const FLOOR_DECORATED_RANGE_1_START = Vector2i(1, 15)
const FLOOR_DECORATED_RANGE_1_END = Vector2i(21, 15)
const FLOOR_DECORATED_RANGE_2_START = Vector2i(0, 16)
const FLOOR_DECORATED_RANGE_2_END = Vector2i(14, 16)
# Cracked ground tile (TileMapLayerCrackedGround) - terrain -2
const CRACKED_TILE = Vector2i(15, 16)
# Fallout: solo floor tiles (cracked/worn) - use center for all single fallout floor tiles
const FALLOUT_CENTER = Vector2i(10, 12) # 274
# Fallout inner wall edges (for larger holes - boundary between hole wall and room floor)
const FALLOUT_INNER_UP_LEFT = Vector2i(9, 11) # 251
const FALLOUT_INNER_UP = Vector2i(10, 11) # 252
const FALLOUT_INNER_UP_RIGHT = Vector2i(11, 11) # 253
const FALLOUT_INNER_RIGHT = Vector2i(11, 12) # 275
const FALLOUT_INNER_DOWN_RIGHT = Vector2i(11, 13) # 297
const FALLOUT_INNER_DOWN = Vector2i(10, 13) # 296
const FALLOUT_INNER_DOWN_LEFT = Vector2i(9, 13) # 295
const FALLOUT_INNER_LEFT = Vector2i(9, 12) # 273
# Fallout inner corners (for larger holes)
const FALLOUT_CORNER_INNER_UP_LEFT = Vector2i(13, 10) # 233
const FALLOUT_CORNER_INNER_UP_RIGHT = Vector2i(16, 10) # 236
const FALLOUT_CORNER_INNER_DOWN_LEFT = Vector2i(13, 13) # 299
const FALLOUT_CORNER_INNER_DOWN_RIGHT = Vector2i(16, 13) # 302
# Room generation parameters # Room generation parameters
# Minimum room size is 3x3 floor tiles, but rooms need 2-tile walls on each side # Minimum room size is 3x3 floor tiles, but rooms need 2-tile walls on each side
@@ -100,13 +121,19 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
# Initialize grid (0 = wall, 1 = floor, 2 = door, 3 = corridor) # Initialize grid (0 = wall, 1 = floor, 2 = door, 3 = corridor)
var grid = [] var grid = []
var tile_grid = [] # Actual tile coordinates (Vector2i) for rendering var tile_grid = [] # Actual tile coordinates (Vector2i) for DungeonLayer0
var decorated_tile_grid = [] # TileMapLayerDecoratedGround: random (0,0) or (1,15)-(21,15), (0,16)-(14,16)
var cracked_tile_grid = [] # TileMapLayerCrackedGround: (15,16) where true; never in doors/corridors/front
for x in range(map_size.x): for x in range(map_size.x):
grid.append([]) grid.append([])
tile_grid.append([]) tile_grid.append([])
decorated_tile_grid.append([])
cracked_tile_grid.append([])
for y in range(map_size.y): for y in range(map_size.y):
grid[x].append(0) # Start with all walls grid[x].append(0) # Start with all walls
tile_grid[x].append(Vector2i(0, 0)) # Default wall tile (will be set properly later) tile_grid[x].append(Vector2i(0, 0)) # Default wall tile (will be set properly later)
decorated_tile_grid[x].append(null) # Filled for floor/corridor only
cracked_tile_grid[x].append(false) # Filled for some floor only (not doors/corridors/front)
var all_rooms = [] var all_rooms = []
var all_doors = [] var all_doors = []
@@ -271,6 +298,18 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
# 7. Render walls around rooms # 7. Render walls around rooms
_render_room_walls(all_rooms, grid, tile_grid, map_size, rng) _render_room_walls(all_rooms, grid, tile_grid, map_size, rng)
# 7.4. Fix inner walls and corners (holes / compound rooms)
_cleanup_compound_room_corners_and_walls(all_rooms, grid, tile_grid, map_size)
# 7.43. Fill decorated ground layer (random per floor/corridor); (0,0) most common
_fill_decorated_tile_grid(grid, decorated_tile_grid, map_size, rng)
# 7.44. Sprinkle cracked floor (15,16) on some floor; never in doors, corridors, in front of doors, or in start room
_fill_cracked_tile_grid(grid, cracked_tile_grid, map_size, all_doors, all_rooms[start_room_index], rng)
# 7.45. Sprinkle fallout tiles on some floor (cracked/worn look); never in doors, corridors, in front of doors, or in start room
_render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng)
# 7.46. Keep fallout tiles free from decorated layer (no decorated tiles on top of fallout)
_clear_decorated_on_fallout(tile_grid, decorated_tile_grid, map_size)
# 7.5. Place stairs in exit room BEFORE placing torches (so torches don't overlap stairs) # 7.5. Place stairs in exit room BEFORE placing torches (so torches don't overlap stairs)
var stairs_data = _place_stairs_in_exit_room(all_rooms[exit_room_index], grid, tile_grid, map_size, all_doors, rng) var stairs_data = _place_stairs_in_exit_room(all_rooms[exit_room_index], grid, tile_grid, map_size, all_doors, rng)
if stairs_data.is_empty(): if stairs_data.is_empty():
@@ -305,9 +344,25 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
var blocking_doors = blocking_doors_result.doors if blocking_doors_result.has("doors") else blocking_doors_result var blocking_doors = blocking_doors_result.doors if blocking_doors_result.has("doors") else blocking_doors_result
var room_puzzle_data = blocking_doors_result.puzzle_data if blocking_doors_result.has("puzzle_data") else {} var room_puzzle_data = blocking_doors_result.puzzle_data if blocking_doors_result.has("puzzle_data") else {}
# Floor switches must NEVER have cracked or fallout: clear cracked_tile_grid and replace fallout with normal floor at all switch positions
var blocking_doors_array = blocking_doors if blocking_doors is Array else blocking_doors.doors
var switch_tiles_seen = {}
for door_data in blocking_doors_array:
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
var sx = door_data.switch_tile_x
var sy = door_data.switch_tile_y
var key = str(sx) + "," + str(sy)
if not key in switch_tiles_seen:
switch_tiles_seen[key] = true
if sx >= 0 and sx < map_size.x and sy >= 0 and sy < map_size.y:
cracked_tile_grid[sx][sy] = false
if _is_fallout_atlas(tile_grid[sx][sy]):
tile_grid[sx][sy] = FLOOR_BASE
LogManager.log("DungeonGenerator: Replaced fallout with normal floor at switch tile (" + str(sx) + "," + str(sy) + ")", LogManager.CATEGORY_DUNGEON)
LogManager.log("DungeonGenerator: Cleared cracked at switch tile (" + str(sx) + "," + str(sy) + ") - switches cannot have cracked ground", LogManager.CATEGORY_DUNGEON)
# Extract rooms with monster spawner puzzles (these should NOT have pre-spawned enemies) # Extract rooms with monster spawner puzzles (these should NOT have pre-spawned enemies)
var rooms_with_spawner_puzzles = [] var rooms_with_spawner_puzzles = []
var blocking_doors_array = blocking_doors if blocking_doors is Array else blocking_doors.doors
for door_data in blocking_doors_array: for door_data in blocking_doors_array:
if "puzzle_type" in door_data and door_data.puzzle_type == "enemy": if "puzzle_type" in door_data and door_data.puzzle_type == "enemy":
if "blocking_room" in door_data and not door_data.blocking_room.is_empty(): if "blocking_room" in door_data and not door_data.blocking_room.is_empty():
@@ -348,7 +403,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
var room = all_rooms[i] var room = all_rooms[i]
# Skip start room and exit room # Skip start room and exit room
if i != start_room_index and i != exit_room_index: if i != start_room_index and i != exit_room_index:
var room_objects = _place_interactable_objects_in_room(room, grid, map_size, all_doors, all_enemies, rng, room_puzzle_data) var room_objects = _place_interactable_objects_in_room(room, grid, tile_grid, map_size, all_doors, all_enemies, rng, room_puzzle_data)
all_interactable_objects.append_array(room_objects) all_interactable_objects.append_array(room_objects)
# 9.6. Place traps (1-2 per level, excluding start and exit rooms) # 9.6. Place traps (1-2 per level, excluding start and exit rooms)
@@ -369,21 +424,20 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
"blocking_doors": blocking_doors, "blocking_doors": blocking_doors,
"grid": grid, "grid": grid,
"tile_grid": tile_grid, "tile_grid": tile_grid,
"decorated_tile_grid": decorated_tile_grid,
"cracked_tile_grid": cracked_tile_grid,
"map_size": map_size, "map_size": map_size,
"start_room": all_rooms[start_room_index], "start_room": all_rooms[start_room_index],
"exit_room": all_rooms[exit_room_index] "exit_room": all_rooms[exit_room_index]
} }
func _set_floor(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator): func _set_floor(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, _rng: RandomNumberGenerator):
# Set floor tiles for the room (interior only, walls will be set separately) # Set floor tiles for the room (interior only); base layer always (0,15)
# Leave 2 tile border for walls (walls are 2 tiles tall)
for x in range(room.x + 2, room.x + room.w - 2): for x in range(room.x + 2, room.x + room.w - 2):
for y in range(room.y + 2, room.y + room.h - 2): for y in range(room.y + 2, room.y + room.h - 2):
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y: if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
# Random floor tile variation tile_grid[x][y] = FLOOR_BASE
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()]
tile_grid[x][y] = floor_tile
func _try_place_room_near(source_room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Dictionary: func _try_place_room_near(source_room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Dictionary:
var attempts = 20 var attempts = 20
@@ -547,13 +601,11 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
if corridor_intersects_other_room.call(corridor_start_x, corridor_y, corridor_end_x, corridor_y, true): if corridor_intersects_other_room.call(corridor_start_x, corridor_y, corridor_end_x, corridor_y, true):
return {} # Corridor would pass through another room, skip this connection return {} # Corridor would pass through another room, skip this connection
# Create corridor (1 tile wide) - use floor tiles # Create corridor (1 tile wide) - base floor (0,15)
# Corridor is between the rooms, after the door
for x in range(wall_x + 1, wall_x + corridor_length + 1): # Corridor starts after the wall for x in range(wall_x + 1, wall_x + corridor_length + 1): # Corridor starts after the wall
if x >= 0 and x < map_size.x and door_y + 1 >= 0 and door_y + 1 < map_size.y: if x >= 0 and x < map_size.x and door_y + 1 >= 0 and door_y + 1 < map_size.y:
grid[x][door_y + 1] = 3 # Corridor (middle row of door) grid[x][door_y + 1] = 3 # Corridor (middle row of door)
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[x][door_y + 1] = FLOOR_BASE
tile_grid[x][door_y + 1] = floor_tile
# Create door on RIGHT wall of left room (2x3 tiles - 2 wide, 3 tall) # Create door on RIGHT wall of left room (2x3 tiles - 2 wide, 3 tall)
# Door is placed ON the wall, replacing the 2-tile wide wall # Door is placed ON the wall, replacing the 2-tile wide wall
@@ -572,8 +624,7 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
tile_grid[x][y] = door_tile_start + Vector2i(door_dx, door_dy) tile_grid[x][y] = door_tile_start + Vector2i(door_dx, door_dy)
if door_dx == 0 and door_dy == 1: if door_dx == 0 and door_dy == 1:
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[x][y] = FLOOR_BASE
tile_grid[x][y] = floor_tile
# Also create door on LEFT wall of right room (if there's a gap) # Also create door on LEFT wall of right room (if there's a gap)
@@ -591,8 +642,7 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
tile_grid[x][y] = right_door_tile_start + Vector2i(door_dx, door_dy) tile_grid[x][y] = right_door_tile_start + Vector2i(door_dx, door_dy)
if door_dx == 1 and door_dy == 1: if door_dx == 1 and door_dy == 1:
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[x][y] = FLOOR_BASE
tile_grid[x][y] = floor_tile
# CRITICAL: room1 = room the door is ON (left room for horizontal doors) # CRITICAL: room1 = room the door is ON (left room for horizontal doors)
# room2 = room the door leads TO (right room for horizontal doors) # room2 = room the door leads TO (right room for horizontal doors)
@@ -639,13 +689,11 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
if corridor_intersects_other_room.call(corridor_x, corridor_start_y, corridor_x, corridor_end_y, false): if corridor_intersects_other_room.call(corridor_x, corridor_start_y, corridor_x, corridor_end_y, false):
return {} # Corridor would pass through another room, skip this connection return {} # Corridor would pass through another room, skip this connection
# Create corridor (1 tile wide) - use floor tiles # Create corridor (1 tile wide) - base floor (0,15)
# Corridor is between the rooms, after the door
for y in range(wall_y + 1, wall_y + corridor_length + 1): # Corridor starts after the wall for y in range(wall_y + 1, wall_y + corridor_length + 1): # Corridor starts after the wall
if door_x + 1 >= 0 and door_x + 1 < map_size.x and y >= 0 and y < map_size.y: if door_x + 1 >= 0 and door_x + 1 < map_size.x and y >= 0 and y < map_size.y:
grid[door_x + 1][y] = 3 # Corridor (middle column of door) grid[door_x + 1][y] = 3 # Corridor (middle column of door)
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[door_x + 1][y] = FLOOR_BASE
tile_grid[door_x + 1][y] = floor_tile
# Create door on BOTTOM wall of top room (3x2 tiles - 3 wide, 2 tall) # Create door on BOTTOM wall of top room (3x2 tiles - 3 wide, 2 tall)
# Door is placed ON the wall, replacing the 2-tile tall wall # Door is placed ON the wall, replacing the 2-tile tall wall
@@ -664,8 +712,7 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
tile_grid[x][y] = door_tile_start + Vector2i(door_dx, door_dy) tile_grid[x][y] = door_tile_start + Vector2i(door_dx, door_dy)
if door_dx == 1 and door_dy == 0: if door_dx == 1 and door_dy == 0:
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[x][y] = FLOOR_BASE
tile_grid[x][y] = floor_tile
# Also create door on TOP wall of bottom room (if there's a gap) # Also create door on TOP wall of bottom room (if there's a gap)
if corridor_length > 0: if corridor_length > 0:
@@ -682,8 +729,7 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
tile_grid[x][y] = bottom_door_tile_start + Vector2i(door_dx, door_dy) tile_grid[x][y] = bottom_door_tile_start + Vector2i(door_dx, door_dy)
if door_dx == 1 and door_dy == 1: if door_dx == 1 and door_dy == 1:
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
var floor_tile = FLOOR_TILES[rng.randi() % FLOOR_TILES.size()] tile_grid[x][y] = FLOOR_BASE
tile_grid[x][y] = floor_tile
# CRITICAL: room1 = room the door is ON (top room for vertical doors) # CRITICAL: room1 = room the door is ON (top room for vertical doors)
# room2 = room the door leads TO (bottom room for vertical doors) # room2 = room the door leads TO (bottom room for vertical doors)
@@ -1037,7 +1083,7 @@ func _add_hole_to_room(room: Dictionary, grid: Array, tile_grid: Array, map_size
var hole_x = rng.randi_range(floor_min_x, max_x) var hole_x = rng.randi_range(floor_min_x, max_x)
var hole_y = rng.randi_range(floor_min_y, max_y) var hole_y = rng.randi_range(floor_min_y, max_y)
# Create hole (back to wall) - use inner wall tiles # Create hole (back to wall) - use fallout inner corner/edge tiles; cleanup pass will refine edges
# Only create hole if the position is currently a floor tile # Only create hole if the position is currently a floor tile
for x in range(hole_x, hole_x + hole_size): for x in range(hole_x, hole_x + hole_size):
for y in range(hole_y, hole_y + hole_size): for y in range(hole_y, hole_y + hole_size):
@@ -1045,18 +1091,17 @@ func _add_hole_to_room(room: Dictionary, grid: Array, tile_grid: Array, map_size
# Only create hole if it's currently a floor tile # Only create hole if it's currently a floor tile
if grid[x][y] == 1: # Floor if grid[x][y] == 1: # Floor
grid[x][y] = 0 # Wall grid[x][y] = 0 # Wall
# Use inner wall tiles for holes # Fallout corner tiles for hole corners; rest get center (cleanup will set edges)
if x == hole_x and y == hole_y: if x == hole_x and y == hole_y:
tile_grid[x][y] = INNER_WALL_TOP_LEFT tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_LEFT
elif x == hole_x + hole_size - 1 and y == hole_y: elif x == hole_x + hole_size - 1 and y == hole_y:
tile_grid[x][y] = INNER_WALL_TOP_RIGHT tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_RIGHT
elif x == hole_x and y == hole_y + hole_size - 1: elif x == hole_x and y == hole_y + hole_size - 1:
tile_grid[x][y] = INNER_WALL_BOTTOM_LEFT tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_LEFT
elif x == hole_x + hole_size - 1 and y == hole_y + hole_size - 1: elif x == hole_x + hole_size - 1 and y == hole_y + hole_size - 1:
tile_grid[x][y] = INNER_WALL_BOTTOM_RIGHT tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_RIGHT
else: else:
# Use default wall tile for interior of hole tile_grid[x][y] = FALLOUT_CENTER
tile_grid[x][y] = WALL_TOP_UPPER
func _find_farthest_room(all_rooms: Array, start_index: int) -> int: func _find_farthest_room(all_rooms: Array, start_index: int) -> int:
var start_room = all_rooms[start_index] var start_room = all_rooms[start_index]
@@ -1250,6 +1295,200 @@ func _render_room_walls(all_rooms: Array, grid: Array, tile_grid: Array, map_siz
if grid[right_x_right][y] != 2: # Not a door (don't overwrite door tiles) if grid[right_x_right][y] != 2: # Not a door (don't overwrite door tiles)
tile_grid[right_x_right][y] = WALL_RIGHT_RIGHT tile_grid[right_x_right][y] = WALL_RIGHT_RIGHT
func _cleanup_compound_room_corners_and_walls(all_rooms: Array, grid: Array, tile_grid: Array, map_size: Vector2i):
# Fix inner walls and corners for rooms that have holes (compound shapes).
# Use fallout inner/corner tiles for hole boundaries (larger holes get proper corners and edges).
for room in all_rooms:
var rx = room.x
var ry = room.y
var rw = room.w
var rh = room.h
# Inner area: skip outer 2-tile perimeter (walls); only consider wall cells inside the room
for x in range(rx + 2, rx + rw - 2):
for y in range(ry + 2, ry + rh - 2):
if x < 0 or x >= map_size.x or y < 0 or y >= map_size.y:
continue
if grid[x][y] != 0:
continue # Not a wall (e.g. floor, door, corridor)
# Wall cell inside room - check 4-neighbors for floor (1) or corridor (3)
var floor_left = (x - 1 >= 0 and (grid[x - 1][y] == 1 or grid[x - 1][y] == 3))
var floor_right = (x + 1 < map_size.x and (grid[x + 1][y] == 1 or grid[x + 1][y] == 3))
var floor_up = (y - 1 >= 0 and (grid[x][y - 1] == 1 or grid[x][y - 1] == 3))
var floor_down = (y + 1 < map_size.y and (grid[x][y + 1] == 1 or grid[x][y + 1] == 3))
# Inner corner: two perpendicular floor neighbors → fallout corner tiles
if floor_right and floor_down:
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_LEFT
elif floor_left and floor_down:
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_RIGHT
elif floor_right and floor_up:
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_LEFT
elif floor_left and floor_up:
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_RIGHT
# Inner edge: single floor neighbor → fallout inner edge tiles
elif floor_down:
tile_grid[x][y] = FALLOUT_INNER_UP
elif floor_up:
tile_grid[x][y] = FALLOUT_INNER_DOWN
elif floor_right:
tile_grid[x][y] = FALLOUT_INNER_LEFT
elif floor_left:
tile_grid[x][y] = FALLOUT_INNER_RIGHT
# Wall with no floor neighbor (hole interior) → fallout center
else:
tile_grid[x][y] = FALLOUT_CENTER
func _is_wall_at(grid: Array, map_size: Vector2i, x: int, y: int) -> bool:
if x < 0 or x >= map_size.x or y < 0 or y >= map_size.y:
return false
return grid[x][y] == 0 or grid[x][y] == 2 # wall or door
func _get_fallout_tile_for_floor(grid: Array, map_size: Vector2i, x: int, y: int) -> Vector2i:
# Choose fallout tile based on adjacent walls (up/right/down/left). Default: fallout_center.
var w_up = _is_wall_at(grid, map_size, x, y - 1)
var w_right = _is_wall_at(grid, map_size, x + 1, y)
var w_down = _is_wall_at(grid, map_size, x, y + 1)
var w_left = _is_wall_at(grid, map_size, x - 1, y)
# Corner tiles: exactly 2 walls (numpad 1/3/7/9) — check first so they take precedence
if w_down and w_left and not w_up and not w_right:
return FALLOUT_CORNER_INNER_UP_RIGHT # numpad 1: down+left
if w_down and w_right and not w_up and not w_left:
return FALLOUT_CORNER_INNER_UP_LEFT # numpad 3: down+right
if w_up and w_left and not w_down and not w_right:
return FALLOUT_CORNER_INNER_DOWN_RIGHT # numpad 7: up+left
if w_up and w_right and not w_down and not w_left:
return FALLOUT_CORNER_INNER_DOWN_LEFT # numpad 9: up+right
# Inner two-wall (wall up+left, up+right, down+right, down+left) — 3 or 4 walls
if w_up and w_left:
return FALLOUT_INNER_UP_LEFT
if w_up and w_right:
return FALLOUT_INNER_UP_RIGHT
if w_down and w_right:
return FALLOUT_INNER_DOWN_RIGHT
if w_down and w_left:
return FALLOUT_INNER_DOWN_LEFT
# Single wall (edge only)
if w_up and not w_right and not w_down and not w_left:
return FALLOUT_INNER_UP
if w_right and not w_up and not w_down and not w_left:
return FALLOUT_INNER_RIGHT
if w_down and not w_up and not w_left and not w_right:
return FALLOUT_INNER_DOWN
if w_left and not w_up and not w_right and not w_down:
return FALLOUT_INNER_LEFT
return FALLOUT_CENTER
func _pick_decorated_tile(rng: RandomNumberGenerator) -> Vector2i:
# (16,16) = most common (plain/non-decorated); then (1,15)-(21,15), (0,16)-(14,16)
if rng.randf() < 0.5:
return FLOOR_DECORATED_PLAIN
var roll = rng.randi() % 36 # 21 + 15 = 36 other options
if roll < 21:
return FLOOR_DECORATED_RANGE_1_START + Vector2i(roll, 0)
return FLOOR_DECORATED_RANGE_2_START + Vector2i(roll - 21, 0)
func _fill_decorated_tile_grid(grid: Array, decorated_tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator):
for x in range(map_size.x):
for y in range(map_size.y):
if grid[x][y] == 1 or grid[x][y] == 3: # Floor or corridor only (not doors)
decorated_tile_grid[x][y] = _pick_decorated_tile(rng)
func _is_tile_in_room_interior(tx: int, ty: int, room: Dictionary) -> bool:
# Room interior excludes 2-tile walls: room.x+2..room.x+room.w-2, room.y+2..room.y+room.h-2
if room.is_empty():
return false
return tx >= room.x + 2 and tx < room.x + room.w - 2 and ty >= room.y + 2 and ty < room.y + room.h - 2
func _fill_cracked_tile_grid(grid: Array, cracked_tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator):
const CHANCE = 0.05 # Not so common
for x in range(map_size.x):
for y in range(map_size.y):
if grid[x][y] != 1:
continue # Only floor (never corridors, doors)
if _is_tile_blocked_for_fallout(x, y, grid, map_size, all_doors):
continue
if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room):
continue # No cracked tiles in start room
if rng.randf() >= CHANCE:
continue
cracked_tile_grid[x][y] = true
func _is_tile_blocked_for_fallout(x: int, y: int, _grid: Array, _map_size: Vector2i, all_doors: Array) -> bool:
# True if (x,y) must NOT get a fallout tile: inside a door (either side), or directly in front of a door
# Each connection has TWO door frames (room1 side and room2 side); we must exclude both
for door in all_doors:
var dx: int = door.x
var dy: int = door.y
var room2 = door.get("room2", null)
if door.get("dir", "E") == "E":
# Horizontal: door frame is 2 wide, 3 tall
# Room1 (left) door: (dx, dy) to (dx+2, dy+3)
if x >= dx and x < dx + 2 and y >= dy and y < dy + 3:
return true
# Room2 (right) door: (room2.x, dy) to (room2.x+2, dy+3)
if room2 is Dictionary:
var r2x: int = room2.x
if x >= r2x and x < r2x + 2 and y >= dy and y < dy + 3:
return true
# Directly in front of door (one tile into room from door)
if x == dx - 1 and y >= dy and y < dy + 3:
return true
if x == dx + 2 and y >= dy and y < dy + 3:
return true
if room2 is Dictionary:
var r2x: int = room2.x
if x == r2x - 1 and y >= dy and y < dy + 3:
return true
if x == r2x + 2 and y >= dy and y < dy + 3:
return true
else:
# Vertical (S): door frame is 3 wide, 2 tall
# Room1 (top) door: (dx, dy) to (dx+3, dy+2)
if x >= dx and x < dx + 3 and y >= dy and y < dy + 2:
return true
# Room2 (bottom) door: (dx, room2.y) to (dx+3, room2.y+2)
if room2 is Dictionary:
var r2y: int = room2.y
if x >= dx and x < dx + 3 and y >= r2y and y < r2y + 2:
return true
if x >= dx and x < dx + 3 and y == dy - 1:
return true
if x >= dx and x < dx + 3 and y == dy + 2:
return true
if room2 is Dictionary:
var r2y: int = room2.y
if x >= dx and x < dx + 3 and y == r2y - 1:
return true
if x >= dx and x < dx + 3 and y == r2y + 2:
return true
return false
func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator):
# Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, or in start room
const CHANCE = 0.08
for x in range(map_size.x):
for y in range(map_size.y):
if grid[x][y] != 1:
continue # Only floor (corridors are 3, door frame is 2)
if _is_tile_blocked_for_fallout(x, y, grid, map_size, all_doors):
continue
if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room):
continue # No fallout tiles in start room
if rng.randf() >= CHANCE:
continue
tile_grid[x][y] = _get_fallout_tile_for_floor(grid, map_size, x, y)
func _is_fallout_atlas(tile: Vector2i) -> bool:
return tile == FALLOUT_CENTER \
or tile == FALLOUT_INNER_UP_LEFT or tile == FALLOUT_INNER_UP or tile == FALLOUT_INNER_UP_RIGHT \
or tile == FALLOUT_INNER_RIGHT or tile == FALLOUT_INNER_DOWN_RIGHT or tile == FALLOUT_INNER_DOWN or tile == FALLOUT_INNER_DOWN_LEFT or tile == FALLOUT_INNER_LEFT \
or tile == FALLOUT_CORNER_INNER_UP_LEFT or tile == FALLOUT_CORNER_INNER_UP_RIGHT or tile == FALLOUT_CORNER_INNER_DOWN_LEFT or tile == FALLOUT_CORNER_INNER_DOWN_RIGHT
func _clear_decorated_on_fallout(tile_grid: Array, decorated_tile_grid: Array, map_size: Vector2i):
for x in range(map_size.x):
for y in range(map_size.y):
if _is_fallout_atlas(tile_grid[x][y]):
decorated_tile_grid[x][y] = null
func _place_enemies_in_room(room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, level: int = 1) -> Array: func _place_enemies_in_room(room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, level: int = 1) -> Array:
# Place enemies in a room, scaled by level # Place enemies in a room, scaled by level
# Level 1: 0-2 enemies per room (fewer) # Level 1: 0-2 enemies per room (fewer)
@@ -1857,7 +2096,7 @@ func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, m
LogManager.log("DungeonGenerator: Force placed " + str(stairs_dir) + " stairs at tile (" + str(stairs_data.x) + "," + str(stairs_data.y) + ") world pos: " + str(stairs_data.world_pos), LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Force placed " + str(stairs_dir) + " stairs at tile (" + str(stairs_data.x) + "," + str(stairs_data.y) + ") world pos: " + str(stairs_data.world_pos), LogManager.CATEGORY_DUNGEON)
return stairs_data return stairs_data
func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size: Vector2i, all_doors: Array, all_enemies: Array, rng: RandomNumberGenerator, room_puzzle_data: Dictionary = {}) -> Array: func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, all_enemies: Array, rng: RandomNumberGenerator, room_puzzle_data: Dictionary = {}) -> Array:
# Place interactable objects in a room # Place interactable objects in a room
# Small rooms (7-8 tiles): 0-1 objects # Small rooms (7-8 tiles): 0-1 objects
# Medium rooms (9-10 tiles): 0-3 objects # Medium rooms (9-10 tiles): 0-3 objects
@@ -2034,6 +2273,17 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size
"room": room "room": room
}) })
# If an interactable spawns on a fallout tile, replace that tile with normal floor
for obj in objects:
if obj.has("position"):
var pos = obj.position
var tx = int(pos.x / tile_size)
var ty = int(pos.y / tile_size)
if tx >= 0 and tx < map_size.x and ty >= 0 and ty < map_size.y:
if _is_fallout_atlas(tile_grid[tx][ty]):
tile_grid[tx][ty] = FLOOR_BASE
LogManager.log("DungeonGenerator: Replaced fallout with normal floor at interactable spawn (" + str(tx) + "," + str(ty) + ")", LogManager.CATEGORY_DUNGEON)
return objects return objects
func _is_valid_interactable_position(world_pos: Vector2, all_doors: Array, all_enemies: Array, room: Dictionary) -> bool: func _is_valid_interactable_position(world_pos: Vector2, all_doors: Array, all_enemies: Array, room: Dictionary) -> bool:
@@ -2837,7 +3087,7 @@ func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_
while attempts > 0 and not trap_placed: while attempts > 0 and not trap_placed:
# Random position in room floor (excluding 2-tile wall border, plus extra safety margin) # Random position in room floor (excluding 2-tile wall border, plus extra safety margin)
var floor_margin = 3 # Extra margin from walls for safety var floor_margin = 3 # Extra margin from walls for safety
var local_x = rng.randi_range(floor_margin, room.w - floor_margin - 1) var local_x = rng.randi_range(floor_margin, room.w - floor_margin - 1)
var local_y = rng.randi_range(floor_margin, room.h - floor_margin - 1) var local_y = rng.randi_range(floor_margin, room.h - floor_margin - 1)
var world_x = room.x + local_x var world_x = room.x + local_x
@@ -2845,7 +3095,7 @@ func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_
# Check if position is valid (floor tile, not blocked) # Check if position is valid (floor tile, not blocked)
if world_x >= 0 and world_x < map_size.x and world_y >= 0 and world_y < map_size.y: if world_x >= 0 and world_x < map_size.x and world_y >= 0 and world_y < map_size.y:
if grid[world_x][world_y] == 1: # Floor tile if grid[world_x][world_y] == 1: # Floor tile
# Check if position is not too close to door (avoid blocking doorways) # Check if position is not too close to door (avoid blocking doorways)
var too_close_to_door = false var too_close_to_door = false
# Simplified check - just ensure we're not right at door position # Simplified check - just ensure we're not right at door position

View File

@@ -33,6 +33,15 @@ var burn_damage_timer: float = 0.0 # Timer for burn damage ticks
var position_z: float = 0.0 var position_z: float = 0.0
var velocity_z: float = 0.0 var velocity_z: float = 0.0
# Fallout (quicksand): humanoids sink like player then die; slimes rotate 45 + scale then die
var fallout_state: bool = false
var fallout_scale_progress: float = 1.0
var fallout_defeat_started: bool = false # Slime-like: simple scale/rotate then die
var died_from_fallout: bool = false # True when _die() was triggered by fallout (delay loot 0.3s)
const FALLOUT_SINK_DURATION: float = 0.5
const FALLOUT_CENTER_THRESHOLD: float = 2.0
const FALLOUT_LOOT_DELAY: float = 0.3 # Seconds after fallout death before spawning loot
# Animation # Animation
enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7} enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7}
var current_direction: Direction = Direction.DOWN var current_direction: Direction = Direction.DOWN
@@ -118,6 +127,71 @@ func _physics_process(delta):
burn_sprite.set_meta("burn_animation_timer", anim_timer) burn_sprite.set_meta("burn_animation_timer", anim_timer)
return return
# Fallout: humanoid sinks like player (FALL anim) then dies; slime rotates 45 + scale then dies
if fallout_state:
velocity = Vector2.ZERO
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
if fallout_scale_progress <= 0.0:
died_from_fallout = true
call_deferred("_die")
return
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
if has_method("_set_animation"):
_set_animation("FALL")
move_and_slide()
return
if fallout_defeat_started:
velocity = Vector2.ZERO
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
if fallout_scale_progress <= 0.0:
died_from_fallout = true
call_deferred("_die")
return
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
rotation = deg_to_rad(45.0)
move_and_slide()
return
# Only ground enemies (position_z <= 0) can fall into fallout; bat has position_z 1 and ignores it
# Humanoid: use 16x16 box check (like player) so any part on fallout triggers; drag toward center then sink
var gw = get_tree().get_first_node_in_group("game_world")
var on_fallout = false
if position_z <= 0.0 and gw:
if "humanoid_type" in self and gw.has_method("_is_player_box_on_fallout_tile"):
on_fallout = gw._is_player_box_on_fallout_tile(global_position, 8.0)
elif gw.has_method("_is_position_on_fallout_tile"):
on_fallout = gw._is_position_on_fallout_tile(global_position)
if on_fallout:
if "humanoid_type" in self:
# Humanoid: drag toward tile center (quicksand pull) then sink when at center
var tile_center = gw._get_closest_fallout_tile_center(global_position) if gw.has_method("_get_closest_fallout_tile_center") else (gw._get_tile_center_at(global_position) if gw.has_method("_get_tile_center_at") else global_position)
var dist_to_center = global_position.distance_to(tile_center)
if dist_to_center < FALLOUT_CENTER_THRESHOLD:
global_position = tile_center
fallout_state = true
fallout_scale_progress = 1.0
velocity = Vector2.ZERO
if has_method("_set_animation"):
_set_animation("FALL")
if has_node("SfxFallout"):
$SfxFallout.play()
else:
# Drag toward center (quicksand pull)
var dir = (tile_center - global_position).normalized()
const FALLOUT_DRAG_STRENGTH: float = 820.0
velocity = dir * FALLOUT_DRAG_STRENGTH * get_process_delta_time()
if has_method("_set_animation"):
_set_animation("RUN")
move_and_slide()
return
else:
# Slime-like: rotate 45 and scale down then die
fallout_defeat_started = true
fallout_scale_progress = 1.0
rotation = deg_to_rad(45.0)
move_and_slide()
return
# Update attack timer # Update attack timer
if attack_timer > 0: if attack_timer > 0:
attack_timer -= delta attack_timer -= delta
@@ -137,6 +211,14 @@ func _physics_process(delta):
if not is_knocked_back: if not is_knocked_back:
_ai_behavior(delta) _ai_behavior(delta)
# Slime, rat, humanoid: try to avoid stepping onto fallout (position_z <= 0 = ground enemies only; bat has position_z 1)
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and velocity.length_squared() > 1.0:
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_is_position_on_fallout_tile"):
var step = velocity.normalized() * 18.0
if game_world._is_position_on_fallout_tile(global_position + step):
velocity = Vector2.ZERO
# Move # Move
move_and_slide() move_and_slide()
@@ -752,8 +834,14 @@ func _die():
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position) game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
# Spawn loot immediately (before death animation) # Spawn loot (immediately, or after 0.3s if died from fallout so it appears after sink)
_spawn_loot() if died_from_fallout:
get_tree().create_timer(FALLOUT_LOOT_DELAY).timeout.connect(func():
if is_instance_valid(self):
_spawn_loot()
)
else:
_spawn_loot()
# Sync death to all clients (only server sends RPC) # Sync death to all clients (only server sends RPC)
# Use game_world to route death sync instead of direct RPC to avoid node path issues # Use game_world to route death sync instead of direct RPC to avoid node path issues
@@ -932,10 +1020,11 @@ func _spawn_loot():
ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world) ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world)
LogManager.log(str(name) + " ✓ dropped item #" + str(i + 1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " ✓ dropped item #" + str(i + 1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY)
else: else:
# Spawn regular loot (coin or food) # Spawn regular loot (coin or food) - start at position_z 1 to avoid falling into fallout
var loot = loot_scene.instantiate() var loot = loot_scene.instantiate()
entities_node.add_child(loot) entities_node.add_child(loot)
loot.global_position = safe_spawn_pos loot.global_position = safe_spawn_pos
loot.position_z = 1.0 if "position_z" in loot else 0.0
loot.loot_type = loot_type loot.loot_type = loot_type
# Set initial velocity before _ready() processes # Set initial velocity before _ready() processes
loot.velocity = initial_velocity loot.velocity = initial_velocity

View File

@@ -28,6 +28,9 @@ func _ready():
state_timer = idle_duration state_timer = idle_duration
# Bats fly: permanent position_z 1 so they ignore fallout tiles
position_z = 1.0
# CRITICAL: Ensure collision mask is set correctly (walls are on layer 7 = bit 6 = 64) # CRITICAL: Ensure collision mask is set correctly (walls are on layer 7 = bit 6 = 64)
collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64) collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)

View File

@@ -34,6 +34,8 @@ func _ready() -> void:
move_speed = 16.8 # 60% of 28.0 - slower chase/random movement move_speed = 16.8 # 60% of 28.0 - slower chase/random movement
damage = SNATCH_DAMAGE damage = SNATCH_DAMAGE
exp_reward = 8.0 exp_reward = 8.0
# Enemy hand is a special enemy: always at position_z 1 so it never falls into fallout
position_z = 1.0
collision_layer = 2 collision_layer = 2
collision_mask = 1 | 2 | 64 collision_mask = 1 | 2 | 64

View File

@@ -173,6 +173,12 @@ const ANIMATIONS = {
"loop": false, "loop": false,
"nextAnimation": null "nextAnimation": null
}, },
"FALL": {
"frames": [21],
"frameDurations": [500],
"loop": true,
"nextAnimation": null
},
"IDLE_HOLD": { "IDLE_HOLD": {
"frames": [25], "frames": [25],
"frameDurations": [500], "frameDurations": [500],
@@ -246,8 +252,8 @@ func _ready():
seed_value = hash(str(spawn_position) + str(humanoid_type) + str(random_component)) seed_value = hash(str(spawn_position) + str(humanoid_type) + str(random_component))
LogManager.log(str(name) + " appearance seed (randomized): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " appearance seed (randomized): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY)
else: else:
# Deterministic based on position and type only # Deterministic based on position, type, and name so each enemy has unique look (name differs per spawn index)
seed_value = hash(str(spawn_position) + str(humanoid_type)) seed_value = hash(str(spawn_position) + str(humanoid_type) + str(name))
LogManager.log(str(name) + " appearance seed (deterministic): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " appearance seed (deterministic): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY)
appearance_rng.seed = seed_value appearance_rng.seed = seed_value
@@ -747,8 +753,8 @@ func _load_random_headgear():
# Available headgears organized by category (using actual files found) # Available headgears organized by category (using actual files found)
var headgear_categories = { var headgear_categories = {
"": ["Headband.png"], # Direct files in Layer 6 - Headgears "": ["Headband.png"], # Direct files in Layer 6 - Headgears
"Basic Assasin": [ "Basic Assassin": [
"AssasinBandanaBlack.png", "StalkerHoodBlack.png", "ThiefBandanaGreen.png" "AssassinBandanaBlack.png", "StalkerHoodBlack.png", "ThiefBandanaGreen.png"
], ],
"Basic Mage": [ "Basic Mage": [
"EsperHatBlue.png", "HighMageHatCyan.png", "MageHatRed.png", "SorcererHoodCyan.png" "EsperHatBlue.png", "HighMageHatCyan.png", "MageHatRed.png", "SorcererHoodCyan.png"
@@ -1138,6 +1144,15 @@ func _ai_behavior(delta):
else: else:
lost_target_timer = 0.0 lost_target_timer = 0.0
func _would_move_into_fallout(move_dir: Vector2, step: float = 24.0) -> bool:
if move_dir.length_squared() < 0.01:
return false
var gw = get_tree().get_first_node_in_group("game_world")
if not gw or not gw.has_method("_is_position_on_fallout_tile"):
return false
var check_pos = global_position + move_dir.normalized() * step
return gw._is_position_on_fallout_tile(check_pos)
func _idle_behavior(_delta): func _idle_behavior(_delta):
velocity = Vector2.ZERO velocity = Vector2.ZERO
@@ -1166,13 +1181,17 @@ func _idle_behavior(_delta):
func _wandering_behavior(_delta): func _wandering_behavior(_delta):
# Patrolling at slower pace # Patrolling at slower pace
# Pick a random direction at the start of wandering # Pick a random direction at the start of wandering (avoid fallout)
if state_timer >= 1.9: # Pick direction at start if state_timer >= 1.9: # Pick direction at start
var random_angle = randf() * PI * 2 var random_angle = randf() * PI * 2
var random_dir = Vector2(cos(random_angle), sin(random_angle)) var random_dir = Vector2(cos(random_angle), sin(random_angle))
velocity = random_dir * move_speed * patrol_speed_multiplier # Slower when patrolling if _would_move_into_fallout(random_dir):
velocity = Vector2.ZERO
_set_animation("IDLE")
else:
velocity = random_dir * move_speed * patrol_speed_multiplier # Slower when patrolling
_set_animation("RUN")
current_direction = _get_direction_from_vector(random_dir) current_direction = _get_direction_from_vector(random_dir)
_set_animation("RUN")
# Check if player enters vision while patrolling # Check if player enters vision while patrolling
if target_player and _is_player_in_vision(target_player): if target_player and _is_player_in_vision(target_player):
@@ -1315,6 +1334,12 @@ func _chasing_behavior(delta_arg):
var desired_distance = 45.0 var desired_distance = 45.0
# Avoid walking into fallout (quicksand)
if _would_move_into_fallout(to_player):
velocity = Vector2.ZERO
current_direction = _get_direction_from_vector(to_player)
return
# Apply speed multiplier if blocking # Apply speed multiplier if blocking
var speed_mult = 1.0 var speed_mult = 1.0
if is_blocking: if is_blocking:

View File

@@ -2,28 +2,28 @@ extends Area2D
# Floor Switch - Activates when enough weight is placed on it # Floor Switch - Activates when enough weight is placed on it
@export_enum("walk", "pillar") var switch_type: String = "walk" # "walk" = walk-on switch (weight 1), "pillar" = requires pillar (weight 5) @export_enum("walk", "pillar") var switch_type: String = "walk" # "walk" = walk-on switch (weight 1), "pillar" = requires pillar (weight 5)
@export var required_weight: float = 1.0 # Required weight to activate (automatically set based on switch_type) @export var required_weight: float = 1.0 # Required weight to activate (automatically set based on switch_type)
var is_activated: bool = false var is_activated: bool = false
var current_weight: float = 0.0 var current_weight: float = 0.0
var objects_on_switch: Array = [] # Track objects currently on the switch var objects_on_switch: Array = [] # Track objects currently on the switch
var tilemap_layer: TileMapLayer = null var tilemap_layer: TileMapLayer = null
var switch_tile_position: Vector2i = Vector2i.ZERO # Tile position in the tilemap var switch_tile_position: Vector2i = Vector2i.ZERO # Tile position in the tilemap
var check_timer: float = 0.0 # Timer for periodic checks (pillar switches only) var check_timer: float = 0.0 # Timer for periodic checks (pillar switches only)
var check_interval: float = 0.2 # Check every 0.2 seconds var check_interval: float = 0.2 # Check every 0.2 seconds
func _ready(): func _ready():
# Set required weight based on switch type # Set required weight based on switch type
if switch_type == "walk": if switch_type == "walk":
required_weight = 1.0 # Player weight only required_weight = 1.0 # Player weight only
elif switch_type == "pillar": elif switch_type == "pillar":
required_weight = 5.0 # Requires pillar (weight 5) required_weight = 5.0 # Requires pillar (weight 5)
# Set collision mask to detect players and objects # Set collision mask to detect players and objects
collision_layer = 0 collision_layer = 0
collision_mask = 1 | 2 # Detect players (layer 1) and objects (layer 2) collision_mask = 1 | 2 # Detect players (layer 1) and objects (layer 2)
# Connect signals # Connect signals
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
@@ -42,16 +42,15 @@ func _ready():
set_process(false) set_process(false)
func _find_tilemap_layer(): func _find_tilemap_layer():
# Find tilemap layer to update switch visual # Find TileMapLayerDecoratedGround to update switch visual (switches are drawn on decorated ground)
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world: if game_world:
if "dungeon_tilemap_layer" in game_world: if "dungeon_tilemap_layer_decorated" in game_world:
tilemap_layer = game_world.dungeon_tilemap_layer tilemap_layer = game_world.dungeon_tilemap_layer_decorated
else: else:
# Try to find it in Environment node
var environment = game_world.get_node_or_null("Environment") var environment = game_world.get_node_or_null("Environment")
if environment: if environment:
tilemap_layer = environment.get_node_or_null("DungeonLayer0") tilemap_layer = environment.get_node_or_null("TileMapLayerDecoratedGround")
func _on_body_entered(body): func _on_body_entered(body):
# Object entered the switch # Object entered the switch
@@ -67,7 +66,7 @@ func _on_body_entered(body):
# Only count pillars that are placed (not being held) # Only count pillars that are placed (not being held)
if object_type == "Pillar" and not is_being_held: if object_type == "Pillar" and not is_being_held:
var weight = _get_object_weight(body) var weight = _get_object_weight(body)
if weight >= required_weight: # Pillar must have weight >= 5.0 if weight >= required_weight: # Pillar must have weight >= 5.0
$PressSwitch.play() $PressSwitch.play()
objects_on_switch.append(body) objects_on_switch.append(body)
current_weight += weight current_weight += weight

View File

@@ -88,6 +88,8 @@ var fog_debug_lines: Array = []
var dungeon_data: Dictionary = {} var dungeon_data: Dictionary = {}
var dungeon_tilemap_layer: TileMapLayer = null var dungeon_tilemap_layer: TileMapLayer = null
var dungeon_tilemap_layer_above: TileMapLayer = null var dungeon_tilemap_layer_above: TileMapLayer = null
var dungeon_tilemap_layer_decorated: TileMapLayer = null
var dungeon_tilemap_layer_cracked: TileMapLayer = null
var current_level: int = 1 var current_level: int = 1
var dungeon_seed: int = 0 var dungeon_seed: int = 0
@@ -111,6 +113,28 @@ var clients_ready: Dictionary = {} # peer_id -> bool
# Track dungeon syncs in progress (server only) - prevent multiple simultaneous syncs # Track dungeon syncs in progress (server only) - prevent multiple simultaneous syncs
var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool
# Fallout tiles (quicksand): last safe tile center per player (for respawn after falling in)
var last_safe_position_by_player: Dictionary = {} # player node path or name -> Vector2
# Cracked floor: stand too long -> tile breaks and becomes fallout
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile)
const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile before it breaks
const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16)
# Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
const _FALLOUT_CENTER = Vector2i(10, 12)
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11)
const _FALLOUT_INNER_UP = Vector2i(10, 11)
const _FALLOUT_INNER_UP_RIGHT = Vector2i(11, 11)
const _FALLOUT_INNER_RIGHT = Vector2i(11, 12)
const _FALLOUT_INNER_DOWN_RIGHT = Vector2i(11, 13)
const _FALLOUT_INNER_DOWN = Vector2i(10, 13)
const _FALLOUT_INNER_DOWN_LEFT = Vector2i(9, 13)
const _FALLOUT_INNER_LEFT = Vector2i(9, 12)
const _FALLOUT_CORNER_INNER_UP_LEFT = Vector2i(13, 10)
const _FALLOUT_CORNER_INNER_UP_RIGHT = Vector2i(16, 10)
const _FALLOUT_CORNER_INNER_DOWN_LEFT = Vector2i(13, 13)
const _FALLOUT_CORNER_INNER_DOWN_RIGHT = Vector2i(16, 13)
# Track chunk acknowledgments (server only) - for flow control # 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} var dungeon_chunk_acks: Dictionary = {} # peer_id -> {chunk_idx -> bool, next_chunk_to_send -> int, chunks_data -> Array}
@@ -449,9 +473,12 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
_push_existing_players_state_to_client(peer_id) _push_existing_players_state_to_client(peer_id)
) )
# Sync broken interactable objects to the new client # Sync broken interactable objects to the new client (immediate + delayed retry so joiner always gets it after objects exist)
# Wait a bit after dungeon sync to ensure objects are spawned first
call_deferred("_sync_broken_objects_to_client", peer_id) call_deferred("_sync_broken_objects_to_client", peer_id)
get_tree().create_timer(2.0).timeout.connect(func():
if is_inside_tree() and multiplayer.is_server():
_sync_broken_objects_to_client(peer_id)
)
# Sync existing enemies (from spawners) to the new client # Sync existing enemies (from spawners) to the new client
# Wait a bit after dungeon sync to ensure spawners are spawned first # Wait a bit after dungeon sync to ensure spawners are spawned first
@@ -1029,6 +1056,7 @@ func _sync_loot_spawn(spawn_position: Vector2, loot_type: int, initial_velocity:
loot.set_meta("loot_id", loot_id) loot.set_meta("loot_id", loot_id)
entities_node.add_child(loot) entities_node.add_child(loot)
loot.global_position = spawn_position loot.global_position = spawn_position
loot.position_z = 1.0 if "position_z" in loot else 0.0
loot.loot_type = loot_type loot.loot_type = loot_type
# Ensure key loot has a deterministic name for any legacy RPCs # Ensure key loot has a deterministic name for any legacy RPCs
if loot_type == 4: # LootType.KEY if loot_type == 4: # LootType.KEY
@@ -1064,6 +1092,7 @@ func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initi
loot.set_meta("loot_id", loot_id) loot.set_meta("loot_id", loot_id)
entities_node.add_child(loot) entities_node.add_child(loot)
loot.global_position = spawn_position loot.global_position = spawn_position
loot.position_z = 1.0 if "position_z" in loot else 0.0
loot.loot_type = loot.LootType.ITEM loot.loot_type = loot.LootType.ITEM
loot.item = item # Set the item instance loot.item = item # Set the item instance
# Set initial velocity before _ready() processes # Set initial velocity before _ready() processes
@@ -1900,6 +1929,67 @@ func _process(delta):
peer_cleanup_timer = 0.0 peer_cleanup_timer = 0.0
_cleanup_disconnected_peers() _cleanup_disconnected_peers()
# Cracked floor: only server (or single-player) checks stand time and breaks tiles.
# On server, check ALL players (host + joiners) so joiners can break cracked tiles too.
if dungeon_tilemap_layer_cracked and (multiplayer.is_server() or not multiplayer.has_multiplayer_peer()):
var players_to_check: Array = get_tree().get_nodes_in_group("player") if (multiplayer.is_server() and multiplayer.has_multiplayer_peer()) else (player_manager.get_local_players() if player_manager else [])
if players_to_check.is_empty() and player_manager:
players_to_check = player_manager.get_local_players()
for player in players_to_check:
if not is_instance_valid(player):
continue
var pos = player.global_position
if player.has_node("QuicksandArea"):
var qa = player.get_node("QuicksandArea")
if is_instance_valid(qa):
pos = qa.global_position
if not _is_position_on_cracked_tile(pos):
# Clear any timer for this player's other tiles
var to_remove = []
var player_key = str(player.get_path()) if player.is_inside_tree() else str(player.name)
for k in cracked_stand_timers.keys():
if k.begins_with(player_key + "|"):
to_remove.append(k)
for k in to_remove:
cracked_stand_timers.erase(k)
continue
var tile = _get_tile_coords_at_world(pos)
if tile.x < 0 or tile.y < 0:
continue
var key = (str(player.get_path()) if player.is_inside_tree() else str(player.name)) + "|" + str(tile.x) + "|" + str(tile.y)
cracked_stand_timers[key] = cracked_stand_timers.get(key, 0.0) + delta
if cracked_stand_timers[key] >= CRACKED_STAND_DURATION:
cracked_stand_timers.erase(key)
_break_cracked_tile(tile.x, tile.y)
# Slime, rat, humanoid: standing on cracked tiles can break them (they don't dodge cracked)
var enemies_to_check = get_tree().get_nodes_in_group("enemy")
for enemy in enemies_to_check:
if not is_instance_valid(enemy):
continue
if "is_dead" in enemy and enemy.is_dead:
continue
var ez = enemy.position_z if "position_z" in enemy else 0.0
if ez > 0.0:
continue
var pos = enemy.global_position
if not _is_position_on_cracked_tile(pos):
var enemy_key = (str(enemy.get_path()) if enemy.is_inside_tree() else str(enemy.name)) + "|"
var to_remove = []
for k in cracked_stand_timers.keys():
if k.begins_with(enemy_key):
to_remove.append(k)
for k in to_remove:
cracked_stand_timers.erase(k)
continue
var tile = _get_tile_coords_at_world(pos)
if tile.x < 0 or tile.y < 0:
continue
var key = (str(enemy.get_path()) if enemy.is_inside_tree() else str(enemy.name)) + "|" + str(tile.x) + "|" + str(tile.y)
cracked_stand_timers[key] = cracked_stand_timers.get(key, 0.0) + delta
if cracked_stand_timers[key] >= CRACKED_STAND_DURATION:
cracked_stand_timers.erase(key)
_break_cracked_tile(tile.x, tile.y)
func _check_client_buffers(current_time: float): func _check_client_buffers(current_time: float):
# Check all client buffers and mark which ones should be skipped # Check all client buffers and mark which ones should be skipped
if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
@@ -2251,8 +2341,8 @@ func _update_mouse_cursor(delta: float):
var mouse_direction = (target_world_pos - player_pos).normalized() var mouse_direction = (target_world_pos - player_pos).normalized()
# Only update facing if mouse is far enough from player # Only update facing if mouse is far enough from player (lock to down during fallout)
if mouse_direction.length() > 0.1: if mouse_direction.length() > 0.1 and not (("fallout_state" in player) and player.fallout_state):
player._update_facing_from_mouse(mouse_direction) player._update_facing_from_mouse(mouse_direction)
else: else:
# Mouse is outside window - disable mouse control (use WASD/movement for direction) # Mouse is outside window - disable mouse control (use WASD/movement for direction)
@@ -2408,6 +2498,267 @@ func _is_walkable_tile(tile_center: Vector2) -> bool:
var v = grid[tile_x][tile_y] var v = grid[tile_x][tile_y]
return v == 1 or v == 2 or v == 3 return v == 1 or v == 2 or v == 3
# Fallout (quicksand) tiles: CustomDataLayer "terrain" int value -1
const FALLOUT_TERRAIN_VALUE: int = -1
func _is_position_on_fallout_tile(world_pos: Vector2) -> bool:
if not dungeon_tilemap_layer:
return false
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos)
if not td:
return false
if not td.get_custom_data("terrain") is int:
return false
return td.get_custom_data("terrain") == FALLOUT_TERRAIN_VALUE
func _is_player_box_on_fallout_tile(player_center: Vector2, box_half_size: float = 8.0) -> bool:
# True if any part of the player's 16x16 box (center ± 8) is on a fallout tile (Link's Awakening style)
var corners = [
player_center,
player_center + Vector2(-box_half_size, -box_half_size),
player_center + Vector2(box_half_size, -box_half_size),
player_center + Vector2(-box_half_size, box_half_size),
player_center + Vector2(box_half_size, box_half_size),
]
for p in corners:
if _is_position_on_fallout_tile(p):
return true
return false
func _get_tile_center_at(world_pos: Vector2) -> Vector2:
if not dungeon_tilemap_layer:
return world_pos
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
return dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
func _get_closest_fallout_tile_center(world_pos: Vector2) -> Vector2:
if not dungeon_tilemap_layer:
return world_pos
var center_tile = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
var best_center: Vector2 = world_pos
var best_dist: float = 1e9
var search_radius = 3
for dx in range(-search_radius, search_radius + 1):
for dy in range(-search_radius, search_radius + 1):
var t = center_tile + Vector2i(dx, dy)
var tile_center = dungeon_tilemap_layer.map_to_local(t) + dungeon_tilemap_layer.global_position
var td = dungeon_tilemap_layer.get_cell_tile_data(t)
if not td:
continue
if not td.get_custom_data("terrain") is int:
continue
if td.get_custom_data("terrain") != FALLOUT_TERRAIN_VALUE:
continue
var d = world_pos.distance_squared_to(tile_center)
if d < best_dist:
best_dist = d
best_center = tile_center
return best_center
# Cracked floor: terrain -2 on TileMapLayerCrackedGround
func _is_position_on_cracked_tile(world_pos: Vector2) -> bool:
if not dungeon_tilemap_layer_cracked:
return false
var tile_pos = dungeon_tilemap_layer_cracked.local_to_map(world_pos - dungeon_tilemap_layer_cracked.global_position)
var td = dungeon_tilemap_layer_cracked.get_cell_tile_data(tile_pos)
if not td:
return false
if not td.get_custom_data("terrain") is int:
return false
return td.get_custom_data("terrain") == -2
func _get_tile_coords_at_world(world_pos: Vector2) -> Vector2i:
if not dungeon_tilemap_layer:
return Vector2i(-1, -1)
return dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
func _is_wall_at_tile(tx: int, ty: int) -> bool:
if dungeon_data.is_empty() or not dungeon_data.has("grid") or not dungeon_data.has("map_size"):
return false
var grid = dungeon_data.grid
var map_size: Vector2i = dungeon_data.map_size
if tx < 0 or tx >= map_size.x or ty < 0 or ty >= map_size.y:
return false
var v = grid[tx][ty]
return v == 0 or v == 2
func _get_fallout_tile_for_floor_at(tx: int, ty: int) -> Vector2i:
var w_up = _is_wall_at_tile(tx, ty - 1)
var w_right = _is_wall_at_tile(tx + 1, ty)
var w_down = _is_wall_at_tile(tx, ty + 1)
var w_left = _is_wall_at_tile(tx - 1, ty)
if w_down and w_left and not w_up and not w_right:
return _FALLOUT_CORNER_INNER_UP_RIGHT
if w_down and w_right and not w_up and not w_left:
return _FALLOUT_CORNER_INNER_UP_LEFT
if w_up and w_left and not w_down and not w_right:
return _FALLOUT_CORNER_INNER_DOWN_RIGHT
if w_up and w_right and not w_down and not w_left:
return _FALLOUT_CORNER_INNER_DOWN_LEFT
if w_up and w_left:
return _FALLOUT_INNER_UP_LEFT
if w_up and w_right:
return _FALLOUT_INNER_UP_RIGHT
if w_down and w_right:
return _FALLOUT_INNER_DOWN_RIGHT
if w_down and w_left:
return _FALLOUT_INNER_DOWN_LEFT
if w_up and not w_right and not w_down and not w_left:
return _FALLOUT_INNER_UP
if w_right and not w_up and not w_down and not w_left:
return _FALLOUT_INNER_RIGHT
if w_down and not w_up and not w_left and not w_right:
return _FALLOUT_INNER_DOWN
if w_left and not w_up and not w_right and not w_down:
return _FALLOUT_INNER_LEFT
return _FALLOUT_CENTER
func _play_whoosh_at(world_pos: Vector2) -> void:
var whoosh = load("res://assets/audio/sfx/wizard/animevox/whoosh_1769364646131.wav") as AudioStream
if not whoosh:
return
var player = AudioStreamPlayer2D.new()
player.stream = whoosh
player.global_position = world_pos
player.bus = "Sfx"
add_child(player)
player.play()
player.finished.connect(player.queue_free)
func break_cracked_tiles_in_radius(world_center: Vector2, radius: float) -> void:
# Break any cracked tiles inside the given world-space circle. Only server performs the break.
# Clients (e.g. joiner's bomb) request the server to do it via RPC.
if not dungeon_tilemap_layer or not dungeon_tilemap_layer_cracked:
return
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
_request_break_cracked_tiles_in_radius.rpc_id(1, world_center, radius)
return
var center_tile = _get_tile_coords_at_world(world_center)
if center_tile.x < 0 or center_tile.y < 0:
return
var tile_radius = int(ceil(radius / 16.0)) + 1
for dx in range(-tile_radius, tile_radius + 1):
for dy in range(-tile_radius, tile_radius + 1):
var tx = center_tile.x + dx
var ty = center_tile.y + dy
var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tx, ty)) + dungeon_tilemap_layer.global_position
if world_center.distance_to(tile_center) > radius:
continue
if not _is_position_on_cracked_tile(tile_center):
continue
_break_cracked_tile(tx, ty)
@rpc("any_peer", "reliable")
func _request_break_cracked_tiles_in_radius(world_center: Vector2, radius: float) -> void:
if not multiplayer.is_server():
return
break_cracked_tiles_in_radius(world_center, radius)
func _break_cracked_tile(tile_x: int, tile_y: int) -> void:
if not dungeon_tilemap_layer or dungeon_data.is_empty():
return
# Replace floor with fallout on main layer
var fallout_tile = _get_fallout_tile_for_floor_at(tile_x, tile_y)
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position
_play_whoosh_at(tile_center)
# Update dungeon_data so re-packed blob for joiners has correct floor (no separate broken list needed)
if multiplayer.is_server() and not dungeon_data.is_empty():
if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0:
var tg = dungeon_data.tile_grid
if tile_x < tg.size() and tile_y < tg[tile_x].size():
tg[tile_x][tile_y] = fallout_tile
if dungeon_data.has("decorated_tile_grid"):
var dg = dungeon_data.decorated_tile_grid
if tile_x < dg.size() and tile_y < dg[tile_x].size():
dg[tile_x][tile_y] = null
if dungeon_data.has("cracked_tile_grid"):
var cg = dungeon_data.cracked_tile_grid
if tile_x < cg.size() and tile_y < cg[tile_x].size():
cg[tile_x][tile_y] = false
if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
_sync_cracked_tile_broke.rpc(tile_x, tile_y, fallout_tile.x, fallout_tile.y)
@rpc("authority", "reliable", "call_remote")
func _sync_cracked_tile_broke(tile_x: int, tile_y: int, fallout_atlas_x: int, fallout_atlas_y: int) -> void:
if multiplayer.is_server():
return
var fallout_tile = Vector2i(fallout_atlas_x, fallout_atlas_y)
if dungeon_tilemap_layer:
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position if dungeon_tilemap_layer else Vector2(tile_x * 16 + 8, tile_y * 16 + 8)
_play_whoosh_at(tile_center)
func update_last_safe_position_for_player(player: Node, world_pos: Vector2) -> void:
# Only store when position is on a non-fallout tile so we never remember a pit as safe
if _is_position_on_fallout_tile(world_pos):
return
var key: String = str(player.get_path()) if player.is_inside_tree() else str(player.name)
last_safe_position_by_player[key] = _get_tile_center_at(world_pos)
func _get_nearest_safe_tile_center(from_world_pos: Vector2) -> Vector2:
# Search outward from the tile at from_world_pos; return nearest safe tile (walkable, not fallout).
# Prioritize previously visited (explored) tiles so we never place the player in a wall or in unseen area.
if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
return from_world_pos
var map_size: Vector2i = dungeon_data.map_size
var center_tile = dungeon_tilemap_layer.local_to_map(from_world_pos - dungeon_tilemap_layer.global_position)
# Pass 1: prefer explored tiles that are walkable and not fallout
if not explored_map.is_empty():
for r in range(0, 10):
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
if abs(dx) != r and abs(dy) != r:
continue
var t = center_tile + Vector2i(dx, dy)
if t.x < 0 or t.x >= map_size.x or t.y < 0 or t.y >= map_size.y:
continue
var tile_center = dungeon_tilemap_layer.map_to_local(t) + dungeon_tilemap_layer.global_position
if not _is_walkable_tile(tile_center):
continue
if _is_position_on_fallout_tile(tile_center):
continue
var idx = t.x + t.y * map_size.x
if idx >= 0 and idx < explored_map.size() and explored_map[idx] == 1:
return tile_center
# Pass 2: any walkable, non-fallout tile (never return a wall)
for r in range(0, 10):
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
if abs(dx) != r and abs(dy) != r:
continue
var t = center_tile + Vector2i(dx, dy)
if t.x < 0 or t.x >= map_size.x or t.y < 0 or t.y >= map_size.y:
continue
var tile_center = dungeon_tilemap_layer.map_to_local(t) + dungeon_tilemap_layer.global_position
if not _is_walkable_tile(tile_center):
continue
if _is_position_on_fallout_tile(tile_center):
continue
return tile_center
return from_world_pos
func get_last_safe_position_for_player(player: Node) -> Vector2:
var key: String = str(player.get_path()) if player.is_inside_tree() else str(player.name)
var stored: Vector2
if last_safe_position_by_player.has(key):
stored = last_safe_position_by_player[key]
else:
stored = player.global_position
# If stored position is on a fallout tile (e.g. never updated or room changed), use nearest safe tile
if _is_position_on_fallout_tile(stored):
return _get_nearest_safe_tile_center(stored)
return stored
func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, _player_pos: Vector2) -> Array: func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, _player_pos: Vector2) -> Array:
var out: Array = [] var out: Array = []
if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"): if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"):
@@ -3151,7 +3502,7 @@ func _generate_dungeon():
LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON)
# Dungeon shader color replacement: 13 original colors (wall x6, ground x5, fallout x2) # Dungeon shader color replacement: 14 original colors (wall x6, ground x5, fallout x3)
const _DUNGEON_ORIGINALS: Array = [ const _DUNGEON_ORIGINALS: Array = [
Color(24 / 255.0, 59 / 255.0, 255 / 255.0), # 0 wall Color(24 / 255.0, 59 / 255.0, 255 / 255.0), # 0 wall
Color(33 / 255.0, 50 / 255.0, 195 / 255.0), # 1 wall Color(33 / 255.0, 50 / 255.0, 195 / 255.0), # 1 wall
@@ -3164,8 +3515,9 @@ const _DUNGEON_ORIGINALS: Array = [
Color(48 / 255.0, 38 / 255.0, 20 / 255.0), # 8 ground Color(48 / 255.0, 38 / 255.0, 20 / 255.0), # 8 ground
Color(143 / 255.0, 71 / 255.0, 112 / 255.0), # 9 ground Color(143 / 255.0, 71 / 255.0, 112 / 255.0), # 9 ground
Color(106 / 255.0, 62 / 255.0, 57 / 255.0), # 10 ground Color(106 / 255.0, 62 / 255.0, 57 / 255.0), # 10 ground
Color(69 / 255.0, 42 / 255.0, 31 / 255.0), # 11 fallout Color(109 / 255.0, 33 / 255.0, 24 / 255.0), # 11 fallout
Color(53 / 255.0, 46 / 255.0, 26 / 255.0), # 12 fallout Color(62 / 255.0, 29 / 255.0, 15 / 255.0), # 12 fallout
Color(2 / 255.0, 0 / 255.0, 4 / 255.0), # 13 fallout (near-black)
] ]
# Original wall slots ordered light→dark (by luminance). Tile art uses these for highlight/mid/shadow. # Original wall slots ordered light→dark (by luminance). Tile art uses these for highlight/mid/shadow.
@@ -3209,7 +3561,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.78, 0.48, 0.24), Color(0.84, 0.54, 0.30), Color(0.58, 0.36, 0.16), Color(0.78, 0.48, 0.24), Color(0.84, 0.54, 0.30), Color(0.58, 0.36, 0.16),
Color(0.72, 0.44, 0.22), Color(0.66, 0.40, 0.20), Color(0.38, 0.30, 0.22), Color(0.72, 0.44, 0.22), Color(0.66, 0.40, 0.20), Color(0.38, 0.30, 0.22),
Color(0.32, 0.28, 0.20), Color(0.32, 0.28, 0.20), Color(0.28, 0.24, 0.18),
] ]
1: # 2⃣ Crimson Void (blood / corruption / danger) 1: # 2⃣ Crimson Void (blood / corruption / danger)
walls = [ walls = [
@@ -3219,7 +3571,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52),
Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
Color(0.22, 0.34, 0.30), Color(0.22, 0.34, 0.30), Color(0.20, 0.30, 0.26),
] ]
2: # 3⃣ Toxic Green (poison / nature / alchemy) 2: # 3⃣ Toxic Green (poison / nature / alchemy)
walls = [ walls = [
@@ -3229,7 +3581,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56), Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56),
Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38), Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38),
Color(0.28, 0.22, 0.32), Color(0.28, 0.22, 0.32), Color(0.24, 0.18, 0.28),
] ]
3: # 4⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility 3: # 4⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility
walls = [ walls = [
@@ -3239,7 +3591,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.58, 0.58, 0.60), Color(0.62, 0.62, 0.64), Color(0.46, 0.46, 0.48), Color(0.58, 0.58, 0.60), Color(0.62, 0.62, 0.64), Color(0.46, 0.46, 0.48),
Color(0.55, 0.55, 0.57), Color(0.50, 0.50, 0.52), Color(0.40, 0.40, 0.42), Color(0.55, 0.55, 0.57), Color(0.50, 0.50, 0.52), Color(0.40, 0.40, 0.42),
Color(0.36, 0.36, 0.38), Color(0.36, 0.36, 0.38), Color(0.32, 0.32, 0.34),
] ]
4: # 5⃣ Royal Purple (arcane royalty / bosses) 4: # 5⃣ Royal Purple (arcane royalty / bosses)
walls = [ walls = [
@@ -3249,7 +3601,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.90, 0.68, 0.22), Color(0.94, 0.74, 0.28), Color(0.72, 0.52, 0.14), Color(0.90, 0.68, 0.22), Color(0.94, 0.74, 0.28), Color(0.72, 0.52, 0.14),
Color(0.84, 0.60, 0.18), Color(0.78, 0.56, 0.16), Color(0.46, 0.36, 0.20), Color(0.84, 0.60, 0.18), Color(0.78, 0.56, 0.16), Color(0.46, 0.36, 0.20),
Color(0.38, 0.30, 0.18), Color(0.38, 0.30, 0.18), Color(0.32, 0.26, 0.16),
] ]
5: # 6⃣ Desert Gold (sand / temples / sunlight) 5: # 6⃣ Desert Gold (sand / temples / sunlight)
walls = [ walls = [
@@ -3259,7 +3611,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52),
Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
Color(0.22, 0.34, 0.30), Color(0.22, 0.34, 0.30), Color(0.20, 0.30, 0.26),
] ]
6: # 7⃣ Ancient Stone (medieval / castles / ruins) 6: # 7⃣ Ancient Stone (medieval / castles / ruins)
walls = [ walls = [
@@ -3269,7 +3621,7 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.35, 0.28, 0.22), Color(0.40, 0.32, 0.26), Color(0.28, 0.22, 0.18), Color(0.35, 0.28, 0.22), Color(0.40, 0.32, 0.26), Color(0.28, 0.22, 0.18),
Color(0.38, 0.30, 0.24), Color(0.32, 0.26, 0.20), Color(0.24, 0.20, 0.16), Color(0.38, 0.30, 0.24), Color(0.32, 0.26, 0.20), Color(0.24, 0.20, 0.16),
Color(0.20, 0.16, 0.14), Color(0.20, 0.16, 0.14), Color(0.18, 0.14, 0.12),
] ]
7: # 8⃣ Infernal Lava (hell / bosses / damage) 7: # 8⃣ Infernal Lava (hell / bosses / damage)
walls = [ walls = [
@@ -3279,11 +3631,11 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
ground_fallout = [ ground_fallout = [
Color(0.32, 0.68, 0.48), Color(0.38, 0.74, 0.54), Color(0.22, 0.52, 0.36), Color(0.32, 0.68, 0.48), Color(0.38, 0.74, 0.54), Color(0.22, 0.52, 0.36),
Color(0.28, 0.62, 0.44), Color(0.26, 0.58, 0.40), Color(0.26, 0.34, 0.28), Color(0.28, 0.62, 0.44), Color(0.26, 0.58, 0.40), Color(0.26, 0.34, 0.28),
Color(0.22, 0.30, 0.24), Color(0.22, 0.30, 0.24), Color(0.20, 0.26, 0.22),
] ]
_: _:
return o.duplicate() return o.duplicate()
if walls.size() == 6 and ground_fallout.size() == 7: if walls.size() == 6 and ground_fallout.size() == 8:
walls = _reorder_wall_colors_by_luminance(walls) walls = _reorder_wall_colors_by_luminance(walls)
var out: Array = [] var out: Array = []
out.append_array(walls) out.append_array(walls)
@@ -3297,26 +3649,27 @@ func _apply_dungeon_color_scheme() -> void:
var shader_res = load("res://shaders/game_world.gdshader") as Shader var shader_res = load("res://shaders/game_world.gdshader") as Shader
if not shader_res: if not shader_res:
return return
for layer in [dungeon_tilemap_layer, dungeon_tilemap_layer_above]: var env_node = get_node_or_null("Environment")
if not layer or not is_instance_valid(layer): if not env_node:
return
for child in env_node.get_children():
if not child is TileMapLayer or not is_instance_valid(child):
continue continue
var layer = child as TileMapLayer
var mat = layer.material var mat = layer.material
if not mat or not (mat is ShaderMaterial): if not mat or not (mat is ShaderMaterial):
mat = ShaderMaterial.new() mat = ShaderMaterial.new()
mat.shader = shader_res mat.shader = shader_res
layer.material = mat layer.material = mat
var sm = mat as ShaderMaterial var sm = mat as ShaderMaterial
for i in range(13): for i in range(14):
var orig = _DUNGEON_ORIGINALS[i] as Color var orig = _DUNGEON_ORIGINALS[i] as Color
var rpl = replace_colors[i] as Color # Index 13: darkest fallout (2,0,4) — keep as-is, don't replace with scheme color
var rpl = (_DUNGEON_ORIGINALS[13] as Color) if i == 13 else (replace_colors[i] as Color)
sm.set_shader_parameter("original_" + str(i), orig) sm.set_shader_parameter("original_" + str(i), orig)
sm.set_shader_parameter("replace_" + str(i), rpl) sm.set_shader_parameter("replace_" + str(i), rpl)
# Index 13 unused; set to no-op (original same as replace, distinct from tile colors)
var neutral = Color(0.0, 0.0, 0.0, 1.0)
sm.set_shader_parameter("original_13", neutral)
sm.set_shader_parameter("replace_13", neutral)
# TileMapLayerAbove: tint ffffff77 for slight transparency # TileMapLayerAbove: tint ffffff77 for slight transparency
if layer == dungeon_tilemap_layer_above: if layer.name == "TileMapLayerAbove":
sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 0x77 / 255.0)) sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 0x77 / 255.0))
else: else:
sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 1.0)) sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 1.0))
@@ -3339,6 +3692,8 @@ func _render_dungeon():
if env_node: if env_node:
dungeon_tilemap_layer = env_node.get_node_or_null("DungeonLayer0") dungeon_tilemap_layer = env_node.get_node_or_null("DungeonLayer0")
dungeon_tilemap_layer_above = env_node.get_node_or_null("TileMapLayerAbove") dungeon_tilemap_layer_above = env_node.get_node_or_null("TileMapLayerAbove")
dungeon_tilemap_layer_decorated = env_node.get_node_or_null("TileMapLayerDecoratedGround")
dungeon_tilemap_layer_cracked = env_node.get_node_or_null("TileMapLayerCrackedGround")
if not dungeon_tilemap_layer: if not dungeon_tilemap_layer:
# Create new TileMapLayer # Create new TileMapLayer
@@ -3506,6 +3861,45 @@ func _render_dungeon():
dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, BLACK_TILE) dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, BLACK_TILE)
above_tiles_placed += 1 above_tiles_placed += 1
# Render decorated ground (on TileMapLayerDecoratedGround) and cracked ground (on TileMapLayerCrackedGround)
if dungeon_tilemap_layer_decorated or dungeon_tilemap_layer_cracked:
for x in range(map_size.x):
for y in range(map_size.y):
if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(x, y))
if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(x, y))
if dungeon_data.has("decorated_tile_grid") and dungeon_tilemap_layer_decorated:
var decorated_tile_grid = dungeon_data.decorated_tile_grid
for x in range(map_size.x):
for y in range(map_size.y):
if grid[x][y] != 1 and grid[x][y] != 3:
continue
if x >= decorated_tile_grid.size() or y >= decorated_tile_grid[x].size():
continue
var dt = decorated_tile_grid[x][y]
if dt != null and dt is Vector2i:
dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt)
if dungeon_data.has("cracked_tile_grid") and dungeon_tilemap_layer_cracked:
var cracked_tile_grid = dungeon_data.cracked_tile_grid
# Floor switch positions must NEVER show cracked ground
var switch_tiles = {}
if dungeon_data.has("blocking_doors"):
var bd = dungeon_data.blocking_doors
var bd_array = bd if bd is Array else (bd.doors if "doors" in bd else [])
for door_data in bd_array:
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
var k = str(door_data.switch_tile_x) + "," + str(door_data.switch_tile_y)
switch_tiles[k] = true
for x in range(map_size.x):
for y in range(map_size.y):
if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size():
continue
if switch_tiles.has(str(x) + "," + str(y)):
continue
if cracked_tile_grid[x][y]:
dungeon_tilemap_layer_cracked.set_cell(Vector2i(x, y), 0, CRACKED_TILE_ATLAS)
LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_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: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON)
LogManager.log("GameWorld: Dungeon rendered on TileMapLayer", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Dungeon rendered on TileMapLayer", LogManager.CATEGORY_DUNGEON)
@@ -3543,25 +3937,48 @@ func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = t
# Find free floor tiles in the room (excluding already assigned positions) # Find free floor tiles in the room (excluding already assigned positions)
var free_tiles = _find_free_floor_tiles_in_room(room, exclude_positions) var free_tiles = _find_free_floor_tiles_in_room(room, exclude_positions)
var room_center_x = (room.x + room.w / 2.0) * tile_size
var room_center_y = (room.y + room.h / 2.0) * tile_size
# Helper: only add spawn point if NOT on a fallout tile (players must never start on fallout)
var try_add_spawn = func(world_pos: Vector2) -> void:
if not _is_position_on_fallout_tile(world_pos):
player_manager.spawn_points.append(world_pos)
# Update player manager spawn points # Update player manager spawn points
if free_tiles.size() > 0: if free_tiles.size() > 0:
# Use free floor tiles as spawn points # Use free floor tiles as spawn points (exclude fallout tiles)
for tile_pos in free_tiles: for tile_pos in free_tiles:
var world_x = tile_pos.x * tile_size + tile_size / 2.0 # Center of tile var world_x = tile_pos.x * tile_size + tile_size / 2.0 # Center of tile
var world_y = tile_pos.y * tile_size + tile_size / 2.0 # Center of tile var world_y = tile_pos.y * tile_size + tile_size / 2.0 # Center of tile
player_manager.spawn_points.append(Vector2(world_x, world_y)) try_add_spawn.call(Vector2(world_x, world_y))
LogManager.log("GameWorld: Updated spawn points with " + str(free_tiles.size()) + " free floor tiles in room", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Updated spawn points with " + str(player_manager.spawn_points.size()) + " safe (non-fallout) floor tiles in room", LogManager.CATEGORY_DUNGEON)
else: else:
# Fallback: Create spawn points in a circle around the room center # Fallback: Create spawn points in a circle around the room center (exclude fallout)
var room_center_x = (room.x + room.w / 2.0) * tile_size
var room_center_y = (room.y + room.h / 2.0) * tile_size
var num_spawn_points = 8 var num_spawn_points = 8
for i in range(num_spawn_points): for i in range(num_spawn_points):
var angle = i * PI * 2 / num_spawn_points var angle = i * PI * 2 / num_spawn_points
var offset = Vector2(cos(angle), sin(angle)) * 30 # 30 pixel radius var offset = Vector2(cos(angle), sin(angle)) * 30 # 30 pixel radius
player_manager.spawn_points.append(Vector2(room_center_x, room_center_y) + offset) try_add_spawn.call(Vector2(room_center_x, room_center_y) + offset)
LogManager.log("GameWorld: Updated spawn points in circle around room center (no free tiles found)", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Updated spawn points in circle (safe count: " + str(player_manager.spawn_points.size()) + ")", LogManager.CATEGORY_DUNGEON)
# If no safe spawn points (e.g. room is all fallout), add room center only if safe; else first non-fallout tile in room
if player_manager.spawn_points.size() == 0:
var room_center = Vector2(room_center_x, room_center_y)
if not _is_position_on_fallout_tile(room_center):
player_manager.spawn_points.append(room_center)
LogManager.log("GameWorld: Fallback spawn at room center (safe)", LogManager.CATEGORY_DUNGEON)
else:
# Search room for any non-fallout floor tile
for tx in range(room.x + 2, room.x + room.w - 2):
for ty in range(room.y + 2, room.y + room.h - 2):
var wp = Vector2(tx * tile_size + tile_size / 2.0, ty * tile_size + tile_size / 2.0)
if not _is_position_on_fallout_tile(wp):
player_manager.spawn_points.append(wp)
LogManager.log("GameWorld: Fallback spawn at first non-fallout tile in room", LogManager.CATEGORY_DUNGEON)
break
if player_manager.spawn_points.size() > 0:
break
func _find_room_at_position(world_pos: Vector2) -> Dictionary: func _find_room_at_position(world_pos: Vector2) -> Dictionary:
# Find which room contains the given world position # Find which room contains the given world position
@@ -3959,10 +4376,12 @@ func _collect_current_world_metadata() -> Dictionary:
} }
func _send_dungeon_blob_sync(client_peer_id: int): func _send_dungeon_blob_sync(client_peer_id: int):
# Send pre-packed dungeon blob chunks with acknowledgment-based flow control # Send dungeon blob chunks with acknowledgment-based flow control.
# Re-pack from current dungeon_data so joiners get up-to-date floor (including broken cracked tiles).
if not is_inside_tree() or not multiplayer.is_server(): if not is_inside_tree() or not multiplayer.is_server():
return return
_pack_dungeon_blob()
if dungeon_blob_chunks.is_empty(): if dungeon_blob_chunks.is_empty():
print("GameWorld: HOST - ERROR: No dungeon blob chunks available!") print("GameWorld: HOST - ERROR: No dungeon blob chunks available!")
return return
@@ -4235,6 +4654,8 @@ func _pack_dungeon_blob():
var full_dungeon_data = { var full_dungeon_data = {
"tile_grid": dungeon_data.get("tile_grid", []), "tile_grid": dungeon_data.get("tile_grid", []),
"grid": dungeon_data.get("grid", []), "grid": dungeon_data.get("grid", []),
"decorated_tile_grid": dungeon_data.get("decorated_tile_grid", []),
"cracked_tile_grid": dungeon_data.get("cracked_tile_grid", []),
"map_size": dungeon_data.get("map_size", Vector2i(72, 72)), "map_size": dungeon_data.get("map_size", Vector2i(72, 72)),
"rooms": dungeon_data.get("rooms", []), "rooms": dungeon_data.get("rooms", []),
"start_room": dungeon_data.get("start_room", {}), "start_room": dungeon_data.get("start_room", {}),
@@ -4399,17 +4820,17 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready 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 # Update spawn points - always use start room for joiners so they never end up in a corridor or outside the world
print("GameWorld: Client - Updating spawn points...") print("GameWorld: Client - Updating spawn points...")
if not host_room.is_empty(): var spawn_room = dungeon_data.start_room if dungeon_data.has("start_room") and not dungeon_data.start_room.is_empty() else host_room
print("GameWorld: Client - Using host's room for spawn points: ", host_room) if not spawn_room.is_empty():
LogManager.log("GameWorld: Using host's room for spawn points", LogManager.CATEGORY_DUNGEON) print("GameWorld: Client - Using start room for joiner spawn points (avoid corridor/outside world)")
_update_spawn_points(host_room) LogManager.log("GameWorld: Using start room for joiner spawn points", LogManager.CATEGORY_DUNGEON)
# Move any existing players to spawn near host _update_spawn_points(spawn_room)
_move_players_to_host_room(host_room) _move_players_to_host_room(spawn_room)
else: else:
print("GameWorld: Client - Host room not available, using start room") print("GameWorld: Client - No start room, using default spawn points")
LogManager.log("GameWorld: Host room not available, using start room", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: No start room for joiner, using default spawn points", LogManager.CATEGORY_DUNGEON)
_update_spawn_points() _update_spawn_points()
# Move all players to start room # Move all players to start room
print("GameWorld: Client - Moving all players to start room...") print("GameWorld: Client - Moving all players to start room...")
@@ -4466,8 +4887,7 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec
print("GameWorld: Client - defeated_enemies dictionary now has ", defeated_enemies.size(), " entries") print("GameWorld: Client - defeated_enemies dictionary now has ", defeated_enemies.size(), " entries")
LogManager.log("GameWorld: Client received " + str(defeated_enemies_list.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK) LogManager.log("GameWorld: Client received " + str(defeated_enemies_list.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK)
# Store broken objects BEFORE objects are spawned # Store broken objects BEFORE objects are spawned (merge, don't replace - so _sync_broken_objects isn't overwritten if it arrived first)
broken_objects.clear()
for obj_index in broken_objects_list: for obj_index in broken_objects_list:
broken_objects[obj_index] = true broken_objects[obj_index] = true
if broken_objects_list.size() > 0: if broken_objects_list.size() > 0:
@@ -4571,7 +4991,7 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec
"seed": seed_value, "seed": seed_value,
"level": level, "level": level,
"host_room": host_room, "host_room": host_room,
"existing_loot": existing_loot_list # Store for spawning after dungeon is ready "existing_loot": existing_loot_list
} }
dungeon_sync_chunks.clear() dungeon_sync_chunks.clear()
dungeon_sync_received_chunks = 0 dungeon_sync_received_chunks = 0
@@ -4889,15 +5309,16 @@ func _reassemble_dungeon_blob():
_sync_loot_spawn(loot_data.position, loot_data.get("loot_type", 0), loot_data.get("velocity", Vector2.ZERO), loot_data.get("velocity_z", 0.0), loot_data.get("loot_id", -1)) _sync_loot_spawn(loot_data.position, loot_data.get("loot_type", 0), loot_data.get("velocity", Vector2.ZERO), loot_data.get("velocity_z", 0.0), loot_data.get("loot_id", -1))
print("GameWorld: Client - Existing loot spawned") print("GameWorld: Client - Existing loot spawned")
# Update spawn points # Update spawn points - always use start room for joiners so they never end up in a corridor or outside the world
print("GameWorld: Client - Updating spawn points...") print("GameWorld: Client - Updating spawn points...")
var host_room = dungeon_sync_metadata.host_room var host_room = dungeon_sync_metadata.host_room
if not host_room.is_empty(): var spawn_room = dungeon_data.start_room if dungeon_data.has("start_room") and not dungeon_data.start_room.is_empty() else host_room
print("GameWorld: Client - Using host's room for spawn points: ", host_room) if not spawn_room.is_empty():
_update_spawn_points(host_room) print("GameWorld: Client - Using start room for joiner spawn points")
_move_players_to_host_room(host_room) _update_spawn_points(spawn_room)
_move_players_to_host_room(spawn_room)
else: else:
print("GameWorld: Client - Host room not available, using start room") print("GameWorld: Client - No start room, using default spawn points")
_update_spawn_points() _update_spawn_points()
# Move all players to start room # Move all players to start room
@@ -5026,15 +5447,16 @@ func _check_and_render_dungeon():
_render_dungeon() _render_dungeon()
print("GameWorld: Client - Dungeon rendered") print("GameWorld: Client - Dungeon rendered")
# Update spawn points # Update spawn points - always use start room for joiners so they never end up in a corridor or outside the world
print("GameWorld: Client - Updating spawn points...") print("GameWorld: Client - Updating spawn points...")
var host_room = dungeon_sync_metadata.host_room var host_room_chunks = dungeon_sync_metadata.host_room
if not host_room.is_empty(): var spawn_room_chunks = dungeon_data.start_room if dungeon_data.has("start_room") and not dungeon_data.start_room.is_empty() else host_room_chunks
print("GameWorld: Client - Using host's room for spawn points: ", host_room) if not spawn_room_chunks.is_empty():
_update_spawn_points(host_room) print("GameWorld: Client - Using start room for joiner spawn points")
_move_players_to_host_room(host_room) _update_spawn_points(spawn_room_chunks)
_move_players_to_host_room(spawn_room_chunks)
else: else:
print("GameWorld: Client - Host room not available, using start room") print("GameWorld: Client - No start room, using default spawn points")
_update_spawn_points() _update_spawn_points()
# Move all players to start room # Move all players to start room
@@ -5272,15 +5694,20 @@ func _spawn_enemies():
if "damage" in enemy_data: if "damage" in enemy_data:
enemy.damage = enemy_data.damage enemy.damage = enemy_data.damage
# If it's a humanoid enemy, set the humanoid_type # If it's a humanoid enemy, set the humanoid_type and spawn_position for unique appearance seed
if enemy_type.ends_with("enemy_humanoid.tscn") and "humanoid_type" in enemy_data: if enemy_type.ends_with("enemy_humanoid.tscn"):
enemy.humanoid_type = enemy_data.humanoid_type if "humanoid_type" in enemy_data:
enemy.humanoid_type = enemy_data.humanoid_type
if "spawn_position" in enemy:
enemy.spawn_position = enemy_data.position
# CRITICAL: Set collision mask BEFORE adding to scene to ensure enemies collide with walls (layer 7 = bit 6 = 64) # CRITICAL: Set collision mask BEFORE adding to scene to ensure enemies collide with walls (layer 7 = bit 6 = 64)
# This overrides any collision_mask set in the scene file # This overrides any collision_mask set in the scene file
enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64) enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
# Add to scene tree AFTER setting authority and stats # Set position BEFORE add_child so humanoid _ready() sees correct global_position for unique appearance seed
enemy.position = enemy_data.position
# Add to scene tree AFTER setting authority, stats, and position
entities_node.add_child(enemy) entities_node.add_child(enemy)
enemy.global_position = enemy_data.position enemy.global_position = enemy_data.position
@@ -9255,14 +9682,14 @@ func _spawn_floor_switch(i_position: Vector2, required_weight: float, tile_x: in
entities_node.add_child(switch) entities_node.add_child(switch)
switch.global_position = i_position switch.global_position = i_position
# Update tilemap to show switch tile (initial inactive state) # Draw switch on TileMapLayerDecoratedGround (terrain 1 = walk, 2 = pillar set in tileset)
if dungeon_tilemap_layer: if dungeon_tilemap_layer_decorated:
var initial_tile: Vector2i var initial_tile: Vector2i
if switch_type == "pillar": if switch_type == "pillar":
initial_tile = Vector2i(16, 9) # Pillar switch inactive initial_tile = Vector2i(16, 9) # Pillar switch inactive (terrain 2)
else: else:
initial_tile = Vector2i(11, 9) # Walk-on switch inactive initial_tile = Vector2i(11, 9) # Walk-on switch inactive (terrain 1)
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, initial_tile) dungeon_tilemap_layer_decorated.set_cell(Vector2i(tile_x, tile_y), 0, initial_tile)
var room_x_str = str(switch_room.get("x", "?")) if switch_room and not switch_room.is_empty() else "?" var room_x_str = str(switch_room.get("x", "?")) if switch_room and not switch_room.is_empty() else "?"
var room_y_str = str(switch_room.get("y", "?")) if switch_room and not switch_room.is_empty() else "?" var room_y_str = str(switch_room.get("y", "?")) if switch_room and not switch_room.is_empty() else "?"

View File

@@ -44,6 +44,11 @@ var is_chest_opened: bool = false
var sync_timer: float = 0.0 var sync_timer: float = 0.0
var sync_interval: float = 0.05 # Sync 20 times per second var sync_interval: float = 0.05 # Sync 20 times per second
# Fallout: sink and disappear (like loot)
var falling_into_fallout: bool = false
var fallout_sink_progress: float = 1.0
const FALLOUT_SINK_DURATION: float = 0.4
func _ready(): func _ready():
# Make sure it's on the interactable layer # Make sure it's on the interactable layer
collision_layer = 2 # Layer 2 for objects collision_layer = 2 # Layer 2 for objects
@@ -76,6 +81,22 @@ func _physics_process(delta):
return return
if not is_frozen: if not is_frozen:
# Fallout: sink and disappear when on ground (not held, not airborne)
if not is_airborne and position_z <= 0.0:
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
if not falling_into_fallout:
falling_into_fallout = true
fallout_sink_progress = 1.0
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
if fallout_sink_progress <= 0.0:
queue_free()
return
if sprite:
sprite.scale = Vector2.ONE * max(0.0, fallout_sink_progress)
move_and_slide()
return
falling_into_fallout = false
# Z-axis physics for airborne boxes # Z-axis physics for airborne boxes
if is_airborne: if is_airborne:
# Apply gravity to Z velocity # Apply gravity to Z velocity

View File

@@ -23,12 +23,12 @@ static func spawn_item_loot(item: Item, position: Vector2, entities_node: Node,
# Create unique seed for this loot item: dungeon_seed + position hash + counter # Create unique seed for this loot item: dungeon_seed + position hash + counter
# Use position hash to make seed unique per spawn location # Use position hash to make seed unique per spawn location
var pos_hash = hash(str(int(position.x)) + "_" + str(int(position.y))) 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 var loot_seed = base_seed + pos_hash + 20000 # Offset to avoid collisions with enemy loot
loot_rng.seed = loot_seed loot_rng.seed = loot_seed
var random_angle = loot_rng.randf() * PI * 2 var random_angle = loot_rng.randf() * PI * 2
var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed
var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed
var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force
# Find safe spawn position if game_world is provided # Find safe spawn position if game_world is provided
@@ -44,8 +44,9 @@ static func spawn_item_loot(item: Item, position: Vector2, entities_node: Node,
# Set properties before adding to scene tree (to avoid physics state change errors) # Set properties before adding to scene tree (to avoid physics state change errors)
loot.global_position = safe_spawn_pos loot.global_position = safe_spawn_pos
loot.position_z = 1.0 if "position_z" in loot else 0.0
loot.loot_type = loot.LootType.ITEM loot.loot_type = loot.LootType.ITEM
loot.item = item # Set the item instance loot.item = item # Set the item instance
# Set initial velocity before _ready() processes # Set initial velocity before _ready() processes
loot.velocity = initial_velocity loot.velocity = initial_velocity
loot.velocity_z = random_velocity_z loot.velocity_z = random_velocity_z

View File

@@ -13,8 +13,9 @@ enum LootType {
@export var loot_type: LootType = LootType.COIN @export var loot_type: LootType = LootType.COIN
# Z-axis physics (like boxes and players) # Z-axis physics (like boxes and players) - start at 1 to avoid instantly falling into fallout
var position_z: float = 0.0 const SPAWN_POSITION_Z: float = 1.0
var position_z: float = 1.0
var velocity_z: float = 0.0 var velocity_z: float = 0.0
var acceleration_z: float = 0.0 var acceleration_z: float = 0.0
var is_airborne: bool = true var is_airborne: bool = true
@@ -57,8 +58,15 @@ var item: Item = null # Item instance (for LootType.ITEM)
# Quantity badge for items with quantity > 1 # Quantity badge for items with quantity > 1
var quantity_badge: Label = null var quantity_badge: Label = null
# Fallout: sink and disappear (no recovery)
var falling_into_fallout: bool = false
var fallout_sink_progress: float = 1.0
const FALLOUT_SINK_DURATION: float = 0.4
func _ready(): func _ready():
add_to_group("loot") add_to_group("loot")
# Always start slightly above ground to prevent instantly falling into fallout
position_z = SPAWN_POSITION_Z
# Setup shadow # Setup shadow
if shadow: if shadow:
@@ -219,6 +227,36 @@ func _physics_process(delta):
# Server (authority): Run physics normally # Server (authority): Run physics normally
if is_server: if is_server:
# Fallout: lock to tile center, stop x/y movement, not collectible, sink then remove
if not is_airborne and position_z <= 0.0:
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
if not falling_into_fallout:
falling_into_fallout = true
fallout_sink_progress = 1.0
# Lock to center of fallout tile and stop all x/y movement
if gw.has_method("_get_tile_center_at"):
global_position = gw._get_tile_center_at(global_position)
velocity = Vector2.ZERO
# Not collectible while falling
if pickup_area:
pickup_area.monitoring = false
pickup_area.monitorable = false
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
if fallout_sink_progress <= 0.0:
# Sync removal to clients so joiner sees loot disappear (same as pickup)
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_remove"):
game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position])
queue_free()
return
scale = Vector2.ONE * max(0.0, fallout_sink_progress)
move_and_slide()
_update_visuals()
return
# Update bounce timer # Update bounce timer
if bounce_timer > 0.0: if bounce_timer > 0.0:
bounce_timer -= delta bounce_timer -= delta
@@ -410,6 +448,8 @@ func _animate_coin(delta):
sprite.frame = frame sprite.frame = frame
func _on_pickup_area_body_entered(body): func _on_pickup_area_body_entered(body):
if falling_into_fallout:
return
if body and body.is_in_group("player") and not body.is_dead: if body and body.is_in_group("player") and not body.is_dead:
# Check if this item was dropped by this player recently (5 second cooldown) # Check if this item was dropped by this player recently (5 second cooldown)
if has_meta("dropped_by_peer_id") and has_meta("drop_time"): if has_meta("dropped_by_peer_id") and has_meta("drop_time"):
@@ -430,6 +470,8 @@ func _on_pickup_area_body_entered(body):
_pickup(body) _pickup(body)
func _pickup(player: Node): func _pickup(player: Node):
if falling_into_fallout:
return
# Prevent multiple pickups # Prevent multiple pickups
if collected: if collected:
print("Loot: Already collected, ignoring pickup") print("Loot: Already collected, ignoring pickup")
@@ -774,6 +816,9 @@ func _request_pickup(player_peer_id: int):
print("Loot: Already collected (collected=", collected, "), ignoring pickup request") print("Loot: Already collected (collected=", collected, "), ignoring pickup request")
return return
if falling_into_fallout:
return
# Set mutex and mark as collected IMMEDIATELY to prevent any race conditions # Set mutex and mark as collected IMMEDIATELY to prevent any race conditions
processing_pickup = true processing_pickup = true
collected = true collected = true

View File

@@ -59,6 +59,7 @@ var controls_disabled: bool = false # True when player has reached exit and cont
# Being held state # Being held state
var being_held_by: Node = null var being_held_by: Node = null
var is_being_held: bool = false # Set by set_being_held(); reliable on all clients for fallout immunity
var grabbed_by_enemy_hand: Node = null # Set when enemy hand grabs (snatch); locks movement until release var grabbed_by_enemy_hand: Node = null # Set when enemy hand grabs (snatch); locks movement until release
var enemy_hand_grab_knockback_time: float = 0.0 # Timer for initial knockback when grabbed var enemy_hand_grab_knockback_time: float = 0.0 # Timer for initial knockback when grabbed
const ENEMY_HAND_GRAB_KNOCKBACK_DURATION: float = 0.15 # Short knockback duration before moving to hand const ENEMY_HAND_GRAB_KNOCKBACK_DURATION: float = 0.15 # Short knockback duration before moving to hand
@@ -143,6 +144,27 @@ var spawn_landing_bounced: bool = false
var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling
var has_seen_exit_this_level: bool = false # Track if player has seen exit notification for current level var has_seen_exit_this_level: bool = false # Track if player has seen exit notification for current level
# Fallout (quicksand) state: sink into tile, then respawn at last safe
var fallout_state: bool = false
var fallout_scale_progress: float = 1.0 # 1.0 -> 0.0 during sink
var fallout_respawn_delay_timer: float = 0.0 # After scale hits 0, wait this long before respawn
var fallout_respawn_stun_timer: float = 0.0 # After respawn from fallout, stun for this long (no control)
var on_fallout_tile_near_sink: bool = false # True when on fallout tile but not yet at center (fast walk plays)
var animation_speed_multiplier: float = 1.0 # 1.0 = normal; >1 when on fallout tile so run anim plays faster
const FALLOUT_CENTER_THRESHOLD: float = 2.0 # Player center must be almost exactly at tile center to sink (Zelda Link's Awakening style)
const FALLOUT_DRAG_STRENGTH: float = 820.0 # Base pull toward fallout center (strong enough to prevent running over)
const FALLOUT_CENTER_PULL_BOOST: float = 1.8 # Pull is stronger near center: at center (1+BOOST)x, at edge 1x
const FALLOUT_DRAG_EDGE_FACTOR: float = 0.45 # At tile edge drag is 45% strength; ramps to 100% toward center
const FALLOUT_MOVEMENT_FACTOR: float = 0.3 # Movement speed on fallout tile (30%) so player cannot run over it
const FALLOUT_TILE_HALF_SIZE: float = 8.0 # Half of tile size (16) for distance-based strength
const FALLOUT_PLAYER_BOX_HALF: float = 8.0 # Player treated as 16x16 box for quicksand (center ± 8)
const FALLOUT_TILE_ANIMATION_SPEED: float = 3.0 # Run animation plays this many times faster when on fallout tile (warning to player)
const FALLOUT_SINK_DURATION: float = 0.5 # Seconds to scale from 1 to 0 (faster sink)
const FALLOUT_RESPAWN_DELAY: float = 0.3 # Seconds after scale reaches 0 before respawning at safe tile
const FALLOUT_RESPAWN_STUN_DURATION: float = 0.3 # Seconds of stun after respawn from fallout
const FALLOUT_RESPAWN_HP_PENALTY: float = 1.0 # HP lost when respawning from fallout
const HELD_POSITION_Z: float = 12.0 # Z height when held/lifted (above ground; immune to fallout)
# Components # Components
# @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites
@onready var shadow = $Shadow @onready var shadow = $Shadow
@@ -150,6 +172,7 @@ var has_seen_exit_this_level: bool = false # Track if player has seen exit notif
@onready var point_light = $PointLight2D @onready var point_light = $PointLight2D
@onready var collision_shape = $CollisionShape2D @onready var collision_shape = $CollisionShape2D
@onready var grab_area = $GrabArea @onready var grab_area = $GrabArea
@onready var quicksand_area = $QuicksandArea
@onready var interaction_indicator = $InteractionIndicator @onready var interaction_indicator = $InteractionIndicator
# Audio # Audio
@@ -1584,9 +1607,10 @@ func _get_collision_extent(node: Node) -> float:
return 8.0 return 8.0
func _update_animation(delta): func _update_animation(delta):
# Update animation frame timing # Update animation frame timing (faster when on fallout tile to warn player)
var frame_duration_sec = ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0 / animation_speed_multiplier
time_since_last_frame += delta time_since_last_frame += delta
if time_since_last_frame >= ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0: if time_since_last_frame >= frame_duration_sec:
current_frame += 1 current_frame += 1
if current_frame >= len(ANIMATIONS[current_animation]["frames"]): if current_frame >= len(ANIMATIONS[current_animation]["frames"]):
current_frame -= 1 # Prevent out of bounds current_frame -= 1 # Prevent out of bounds
@@ -1972,7 +1996,26 @@ func _physics_process(delta):
was_reviving_last_frame = false was_reviving_last_frame = false
_stop_spell_charge_incantation() _stop_spell_charge_incantation()
if is_local_player and is_multiplayer_authority(): # Fallout (quicksand) sink: run for ALL players so remote see scale/rotation/FALL animation
if fallout_state:
current_direction = Direction.DOWN
facing_direction_vector = Vector2.DOWN
_set_animation("FALL")
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
rotation = deg_to_rad(45.0)
velocity = Vector2.ZERO
if fallout_respawn_delay_timer > 0.0:
fallout_respawn_delay_timer -= delta
if fallout_respawn_delay_timer <= 0.0:
fallout_respawn_delay_timer = 0.0
if is_multiplayer_authority():
_respawn_from_fallout()
else:
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
if fallout_scale_progress <= 0.0:
fallout_scale_progress = 0.0
fallout_respawn_delay_timer = FALLOUT_RESPAWN_DELAY
elif is_local_player and is_multiplayer_authority():
# When dead: only corpse knockback friction + sync; no input or other logic # When dead: only corpse knockback friction + sync; no input or other logic
if is_dead: if is_dead:
if is_knocked_back: if is_knocked_back:
@@ -1983,6 +2026,42 @@ func _physics_process(delta):
else: else:
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0) velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
else: else:
# Reset fallout-tile animation state each frame (set when on fallout tile below)
animation_speed_multiplier = 1.0
on_fallout_tile_near_sink = false
# Held players cannot fallout (use is_being_held so it works on all clients; position can put held player over a tile)
var is_held = is_being_held or (being_held_by != null and is_instance_valid(being_held_by))
if position_z == 0.0 and not is_held:
# Quicksand: only when player CENTER is on a fallout tile (avoids vortex pull from adjacent ground)
var gw = get_tree().get_first_node_in_group("game_world")
var area_center = quicksand_area.global_position if quicksand_area else global_position
if gw and gw.has_method("_is_position_on_fallout_tile"):
if not gw._is_position_on_fallout_tile(area_center):
gw.update_last_safe_position_for_player(self, global_position)
else:
# Center is on fallout: use this tile's center for drag/sink (symmetric)
var tile_center = gw._get_tile_center_at(area_center)
var dist_to_center = area_center.distance_to(tile_center)
if dist_to_center < FALLOUT_CENTER_THRESHOLD:
# If carrying something, throw it in the direction we were looking before falling
if held_object and is_lifting:
_force_throw_held_object(facing_direction_vector)
# Snap player center exactly to fallout tile center so sink looks correct
global_position = tile_center
fallout_state = true
fallout_scale_progress = 1.0
velocity = Vector2.ZERO
current_direction = Direction.DOWN
facing_direction_vector = Vector2.DOWN
_set_animation("FALL")
if has_node("SfxFallout"):
$SfxFallout.play()
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_fallout_start", [tile_center])
else:
on_fallout_tile_near_sink = true
animation_speed_multiplier = FALLOUT_TILE_ANIMATION_SPEED
_set_animation("RUN")
# Handle knockback timer (always handle knockback, even when controls are disabled) # Handle knockback timer (always handle knockback, even when controls are disabled)
if is_knocked_back: if is_knocked_back:
knockback_time += delta knockback_time += delta
@@ -1994,6 +2073,12 @@ func _physics_process(delta):
if grabbed_by_enemy_hand: if grabbed_by_enemy_hand:
enemy_hand_grab_knockback_time += delta enemy_hand_grab_knockback_time += delta
# Update fallout respawn stun timer (no control for 0.3s after respawn from fallout)
if fallout_respawn_stun_timer > 0.0:
fallout_respawn_stun_timer -= delta
if fallout_respawn_stun_timer <= 0.0:
fallout_respawn_stun_timer = 0.0
# Update movement lock timer (for bow release) # Update movement lock timer (for bow release)
if movement_lock_timer > 0.0: if movement_lock_timer > 0.0:
movement_lock_timer -= delta movement_lock_timer -= delta
@@ -2084,18 +2169,18 @@ func _physics_process(delta):
burn_damage_timer = 0.0 burn_damage_timer = 0.0
_remove_burn_debuff() _remove_burn_debuff()
# Skip input if controls are disabled (e.g., when inventory is open) or spawn landing (fall → DIE → stand up) # Skip input if controls are disabled (e.g., when inventory is open) or spawn landing (fall → DIE → stand up) or fallout (sinking) or post-fallout stun
# But still allow knockback to continue (handled above) # But still allow knockback to continue (handled above)
# CRITICAL: During entrance walk-out cut-scene, game_world sets velocity; do NOT zero it here # CRITICAL: During entrance walk-out cut-scene, game_world sets velocity; do NOT zero it here
var entrance_walk_out = controls_disabled and has_meta("entrance_walk_target") var entrance_walk_out = controls_disabled and has_meta("entrance_walk_target")
var skip_input = controls_disabled or spawn_landing var skip_input = controls_disabled or spawn_landing or fallout_state or (fallout_respawn_stun_timer > 0.0)
if controls_disabled or spawn_landing: if controls_disabled or spawn_landing or fallout_state or (fallout_respawn_stun_timer > 0.0):
if not is_knocked_back and not entrance_walk_out: if not is_knocked_back and not entrance_walk_out:
# Immediately stop movement when controls are disabled (e.g., inventory opened) # Immediately stop movement when controls are disabled (e.g., inventory opened)
# Exception: entrance walk-out - velocity is driven by game_world for cut-scene # Exception: entrance walk-out - velocity is driven by game_world for cut-scene
velocity = Vector2.ZERO velocity = Vector2.ZERO
# Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up) # Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up)
if not spawn_landing and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH": if not spawn_landing and not fallout_state and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH":
if is_lifting: if is_lifting:
_set_animation("IDLE_HOLD") _set_animation("IDLE_HOLD")
elif is_pushing: elif is_pushing:
@@ -2152,6 +2237,24 @@ func _physics_process(delta):
struggle_time = 0.0 # Reset struggle timer struggle_time = 0.0 # Reset struggle timer
struggle_direction = Vector2.ZERO struggle_direction = Vector2.ZERO
_handle_input() _handle_input()
# Apply quicksand only when player CENTER is on a fallout tile (no vortex pull from adjacent tiles)
if position_z == 0.0 and not (is_being_held or (being_held_by != null and is_instance_valid(being_held_by))):
var gw = get_tree().get_first_node_in_group("game_world")
var area_center = quicksand_area.global_position if quicksand_area else global_position
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(area_center):
# Heavy movement penalty so running over the tile is not possible
velocity *= FALLOUT_MOVEMENT_FACTOR
var tile_center = gw._get_tile_center_at(area_center)
var area_center_dist = area_center.distance_to(tile_center)
if area_center_dist >= FALLOUT_CENTER_THRESHOLD:
# Drag toward this tile's center (same strength from all directions)
var dir = (tile_center - area_center).normalized()
# Softer at edge: drag ramps from FALLOUT_DRAG_EDGE_FACTOR at tile edge to 1.0 toward center
var edge_t = clamp(area_center_dist / FALLOUT_TILE_HALF_SIZE, 0.0, 1.0)
var edge_drag_factor = lerp(1.0, FALLOUT_DRAG_EDGE_FACTOR, edge_t)
# Strength: stronger when player center is closer to fallout tile center (distance-based only, no direction bias)
var strength_mult = 1.0 + FALLOUT_CENTER_PULL_BOOST * (1.0 - clamp(area_center_dist / FALLOUT_TILE_HALF_SIZE, 0.0, 1.0))
velocity += dir * FALLOUT_DRAG_STRENGTH * strength_mult * edge_drag_factor * delta
_handle_movement(delta) _handle_movement(delta)
_handle_interactions() _handle_interactions()
else: else:
@@ -3301,10 +3404,9 @@ func _try_grab():
closest_body.set_collision_mask_value(2, false) closest_body.set_collision_mask_value(2, false)
closest_body.set_collision_mask_value(7, true) # Keep wall collision closest_body.set_collision_mask_value(7, true) # Keep wall collision
elif _is_player(closest_body): elif _is_player(closest_body):
# Players: remove from layer fully when lifted no collision with anything # Players: no collision layer at all while held
closest_body.set_collision_layer_value(1, false) closest_body.collision_layer = 0
closest_body.set_collision_mask_value(1, false) closest_body.collision_mask = 0
closest_body.set_collision_mask_value(7, false)
# When grabbing, immediately try to lift if possible # When grabbing, immediately try to lift if possible
_set_animation("IDLE") _set_animation("IDLE")
@@ -3363,10 +3465,14 @@ func _lift_object():
if "is_frozen" in held_object: if "is_frozen" in held_object:
held_object.is_frozen = true held_object.is_frozen = true
elif _is_player(held_object): elif _is_player(held_object):
# Player: use set_being_held # Player: use set_being_held (also sets position_z = HELD_POSITION_Z)
if held_object.has_method("set_being_held"): if held_object.has_method("set_being_held"):
held_object.set_being_held(true) held_object.set_being_held(true)
# Any held object with position_z gets lifted above ground
if "position_z" in held_object:
held_object.position_z = HELD_POSITION_Z
if held_object.has_method("on_lifted"): if held_object.has_method("on_lifted"):
held_object.on_lifted(self) held_object.on_lifted(self)
@@ -3453,11 +3559,12 @@ func reset_grab_state():
if "held_by_player" in held_object: if "held_by_player" in held_object:
held_object.held_by_player = null held_object.held_by_player = null
elif _is_player(held_object): elif _is_player(held_object):
held_object.set_collision_layer_value(1, true) held_object.collision_layer = 1
held_object.set_collision_mask_value(1, true) held_object.collision_mask = 1 | 2 | 64 # players, objects, walls
held_object.set_collision_mask_value(7, true)
if held_object.has_method("set_being_held"): if held_object.has_method("set_being_held"):
held_object.set_being_held(false) held_object.set_being_held(false)
if "position_z" in held_object:
held_object.position_z = 0.0
# Stop drag sound if playing # Stop drag sound if playing
if held_object.has_method("stop_drag_sound"): if held_object.has_method("stop_drag_sound"):
@@ -3515,14 +3622,16 @@ func _stop_pushing():
released_obj.set_collision_mask_value(2, true) released_obj.set_collision_mask_value(2, true)
released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! released_obj.set_collision_mask_value(7, true) # Re-enable wall collision!
elif _is_player(released_obj): elif _is_player(released_obj):
# Players: back on layer 1 # Players: restore collision layer and mask (layer 1, mask 1|2|64 so we collide with players, objects, walls)
released_obj.set_collision_layer_value(1, true) released_obj.collision_layer = 1
released_obj.set_collision_mask_value(1, true) released_obj.collision_mask = 1 | 2 | 64
released_obj.set_collision_mask_value(7, true) # Re-enable wall collision!
if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"): if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"):
released_obj.set_being_held(false) released_obj.set_being_held(false)
if "position_z" in released_obj:
released_obj.position_z = 0.0
# Ensure position stays exactly where it is - no movement on release! # Ensure position stays exactly where it is - no movement on release!
# Do this AFTER calling on_released in case it tries to change position # Do this AFTER calling on_released in case it tries to change position
if released_obj.has_method("on_released"): if released_obj.has_method("on_released"):
@@ -3640,10 +3749,11 @@ func _throw_object():
if thrown_obj.has_method("set_being_held"): if thrown_obj.has_method("set_being_held"):
thrown_obj.set_being_held(false) thrown_obj.set_being_held(false)
# Re-add to layer DIRECTLY when thrown (no delay) # Re-add to layer DIRECTLY when thrown (no delay); restore full mask 1|2|64
if thrown_obj and is_instance_valid(thrown_obj): if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_layer_value(1, true)
thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(1, true)
thrown_obj.set_collision_mask_value(2, true)
thrown_obj.set_collision_mask_value(7, true) thrown_obj.set_collision_mask_value(7, true)
elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed():
# Bomb or other grabbable object - handle like box # Bomb or other grabbable object - handle like box
@@ -3785,9 +3895,8 @@ func _force_throw_held_object(direction: Vector2):
# Re-add to layer DIRECTLY when thrown (no delay) # Re-add to layer DIRECTLY when thrown (no delay)
if thrown_obj and is_instance_valid(thrown_obj): if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(1, true) thrown_obj.collision_layer = 1
thrown_obj.set_collision_mask_value(1, true) thrown_obj.collision_mask = 1 | 2 | 64 # players, objects, walls
thrown_obj.set_collision_mask_value(7, true)
elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed():
# Other grabbable object - handle like box # Other grabbable object - handle like box
thrown_obj.global_position = throw_start_pos thrown_obj.global_position = throw_start_pos
@@ -3917,10 +4026,9 @@ func _place_down_object():
if "velocity_z" in placed_obj: if "velocity_z" in placed_obj:
placed_obj.velocity_z = 0.0 placed_obj.velocity_z = 0.0
elif _is_player(placed_obj): elif _is_player(placed_obj):
# Player: back on layer 1 # Player: restore collision layer and mask (1|2|64 so we collide with players, objects, walls)
placed_obj.set_collision_layer_value(1, true) placed_obj.collision_layer = 1
placed_obj.set_collision_mask_value(1, true) placed_obj.collision_mask = 1 | 2 | 64
placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision!
placed_obj.global_position = place_pos placed_obj.global_position = place_pos
placed_obj.velocity = Vector2.ZERO placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"): if placed_obj.has_method("set_being_held"):
@@ -5223,7 +5331,7 @@ func _update_lifted_object():
held_object = null held_object = null
return return
# Object floats above player's head # Object floats above player's head (XY) and at HELD_POSITION_Z (above ground, immune to fallout)
var target_pos = position + Vector2(0, -12) # Above head var target_pos = position + Vector2(0, -12) # Above head
# Instant follow for local player, smooth for network sync # Instant follow for local player, smooth for network sync
@@ -5232,6 +5340,10 @@ func _update_lifted_object():
else: else:
held_object.global_position = held_object.global_position.lerp(target_pos, 0.3) held_object.global_position = held_object.global_position.lerp(target_pos, 0.3)
# Keep held object at Z height so it's "above" ground (no fallout under it)
if "position_z" in held_object:
held_object.position_z = HELD_POSITION_Z
# Sync held object position over network # Sync held object position over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object) var obj_name = _get_object_name_for_sync(held_object)
@@ -5301,6 +5413,12 @@ func _update_pushed_object():
var results = space_state.intersect_point(query) var results = space_state.intersect_point(query)
was_blocked = results.size() > 0 was_blocked = results.size() > 0
# StonePillar must NOT be pushed onto fallout - treat fallout as solid
if not was_blocked and held_object.get("object_type") == "Pillar":
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(target_pos):
was_blocked = true
# Update the flag for next frame's input handling # Update the flag for next frame's input handling
object_blocked_by_wall = was_blocked object_blocked_by_wall = was_blocked
@@ -5777,10 +5895,11 @@ func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_n
print("Player is now airborne on client!") print("Player is now airborne on client!")
# Re-add to layer DIRECTLY when thrown (no delay) # Re-add to layer DIRECTLY when thrown (no delay); full mask 1|2|64
if obj and is_instance_valid(obj): if obj and is_instance_valid(obj):
obj.set_collision_layer_value(1, true) obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true) obj.set_collision_mask_value(7, true)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
@@ -5818,9 +5937,8 @@ func _sync_initial_grab(obj_name: String, _offset: Vector2):
obj.set_collision_mask_value(1, false) obj.set_collision_mask_value(1, false)
obj.set_collision_mask_value(2, false) obj.set_collision_mask_value(2, false)
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, false) obj.collision_layer = 0
obj.set_collision_mask_value(1, false) obj.collision_mask = 0
obj.set_collision_mask_value(7, false)
print("Synced initial grab on client: ", obj_name) print("Synced initial grab on client: ", obj_name)
@@ -5868,9 +5986,8 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO):
if "throw_velocity" in obj: if "throw_velocity" in obj:
obj.throw_velocity = Vector2.ZERO obj.throw_velocity = Vector2.ZERO
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, false) obj.collision_layer = 0
obj.set_collision_mask_value(1, false) obj.collision_mask = 0
obj.set_collision_mask_value(7, false)
if obj.has_method("set_being_held"): if obj.has_method("set_being_held"):
obj.set_being_held(true) obj.set_being_held(true)
else: else:
@@ -5887,9 +6004,8 @@ func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO):
if "throw_velocity" in obj: if "throw_velocity" in obj:
obj.throw_velocity = Vector2.ZERO obj.throw_velocity = Vector2.ZERO
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, false) obj.collision_layer = 0
obj.set_collision_mask_value(1, false) obj.collision_mask = 0
obj.set_collision_mask_value(7, false)
if obj.has_method("set_being_held"): if obj.has_method("set_being_held"):
obj.set_being_held(true) obj.set_being_held(true)
@@ -5940,6 +6056,7 @@ func _sync_release(obj_name: String):
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, true) obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true) # Re-enable wall collision! obj.set_collision_mask_value(7, true) # Re-enable wall collision!
if obj.has_method("set_being_held"): if obj.has_method("set_being_held"):
obj.set_being_held(false) obj.set_being_held(false)
@@ -6000,6 +6117,7 @@ func _sync_place_down(obj_name: String, place_pos: Vector2):
elif _is_player(obj): elif _is_player(obj):
obj.set_collision_layer_value(1, true) obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true) # Re-enable wall collision! obj.set_collision_mask_value(7, true) # Re-enable wall collision!
obj.velocity = Vector2.ZERO obj.velocity = Vector2.ZERO
if obj.has_method("set_being_held"): if obj.has_method("set_being_held"):
@@ -6126,6 +6244,7 @@ func _break_free_from_holder():
struggle_time = 0.0 struggle_time = 0.0
struggle_direction = Vector2.ZERO struggle_direction = Vector2.ZERO
being_held_by = null being_held_by = null
is_being_held = false
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_break_free(holder_name: String, direction: Vector2): func _sync_break_free(holder_name: String, direction: Vector2):
@@ -6182,6 +6301,7 @@ func _force_place_down(direction: Vector2):
elif _is_player(placed_obj): elif _is_player(placed_obj):
placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_layer_value(1, true)
placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(1, true)
placed_obj.set_collision_mask_value(2, true)
placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision!
placed_obj.velocity = Vector2.ZERO placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"): if placed_obj.has_method("set_being_held"):
@@ -6193,19 +6313,22 @@ func _force_place_down(direction: Vector2):
print("Forced to place down ", placed_obj.name) print("Forced to place down ", placed_obj.name)
func set_being_held(held: bool): func set_being_held(held: bool):
# When being held by another player, disable movement # When being held by another player, disable movement and collision; use HELD_POSITION_Z so we're "above" ground (immune to fallout)
# But keep physics_process running for network sync is_being_held = held
if held: if held:
# Just prevent input handling, don't disable physics
velocity = Vector2.ZERO velocity = Vector2.ZERO
is_airborne = false is_airborne = false
position_z = 0.0 position_z = HELD_POSITION_Z
velocity_z = 0.0 velocity_z = 0.0
collision_layer = 0
collision_mask = 0
else: else:
# Released - reset struggle state
struggle_time = 0.0 struggle_time = 0.0
struggle_direction = Vector2.ZERO struggle_direction = Vector2.ZERO
being_held_by = null being_held_by = null
position_z = 0.0
collision_layer = 1
collision_mask = 1 | 2 | 64 # layer 1 players, 2 objects, 7 walls
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void: func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void:
@@ -6257,10 +6380,13 @@ func rpc_take_damage(amount: float, attacker_position: Vector2, is_burn_damage:
if is_multiplayer_authority(): if is_multiplayer_authority():
take_damage(amount, attacker_position, is_burn_damage, apply_burn_debuff) take_damage(amount, attacker_position, is_burn_damage, apply_burn_debuff)
func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool = false, apply_burn_debuff: bool = false): func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool = false, apply_burn_debuff: bool = false, no_knockback: bool = false):
# Don't take damage if already dead # Don't take damage if already dead
if is_dead: if is_dead:
return return
# Invulnerable during fallout sink (can't take damage from anything while falling)
if fallout_state:
return
# Cancel bow charging when taking damage # Cancel bow charging when taking damage
if is_charging_bow: if is_charging_bow:
@@ -6391,8 +6517,8 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
# Lock facing direction briefly so player can't change it while taking damage # Lock facing direction briefly so player can't change it while taking damage
damage_direction_lock_timer = damage_direction_lock_duration damage_direction_lock_timer = damage_direction_lock_duration
# Only apply knockback if not burn damage # Only apply knockback if not burn damage and not suppressed (e.g. fallout respawn)
if not is_burn_damage: if not is_burn_damage and not no_knockback:
# Calculate direction FROM attacker TO victim # Calculate direction FROM attacker TO victim
var direction_from_attacker = (global_position - attacker_position).normalized() var direction_from_attacker = (global_position - attacker_position).normalized()
@@ -6476,6 +6602,7 @@ func _die():
elif _is_player(released_obj): elif _is_player(released_obj):
released_obj.set_collision_layer_value(1, true) released_obj.set_collision_layer_value(1, true)
released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(1, true)
released_obj.set_collision_mask_value(2, true)
released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! released_obj.set_collision_mask_value(7, true) # Re-enable wall collision!
if released_obj.has_method("set_being_held"): if released_obj.has_method("set_being_held"):
released_obj.set_being_held(false) released_obj.set_being_held(false)
@@ -6539,10 +6666,9 @@ func _die():
other_player.grab_offset = Vector2.ZERO other_player.grab_offset = Vector2.ZERO
other_player.push_axis = Vector2.ZERO other_player.push_axis = Vector2.ZERO
# Re-enable our collision # Re-enable our collision (layer 1, mask 1|2|64)
set_collision_layer_value(1, true) collision_layer = 1
set_collision_mask_value(1, true) collision_mask = 1 | 2 | 64
set_collision_mask_value(7, true) # Re-enable wall collision!
# THEN sync to other clients # THEN sync to other clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
@@ -6555,6 +6681,7 @@ func _die():
print(name, " is NOT being held by anyone") print(name, " is NOT being held by anyone")
being_held_by = null being_held_by = null
is_being_held = false
# Replicas: no wait loop; we get _sync_respawn from authority. # Replicas: no wait loop; we get _sync_respawn from authority.
if not is_multiplayer_authority(): if not is_multiplayer_authority():
@@ -6666,6 +6793,24 @@ func _spawn_landing_stand_up():
if game_world and game_world.has_method("_start_bg_music"): if game_world and game_world.has_method("_start_bg_music"):
game_world._start_bg_music() game_world._start_bg_music()
func _respawn_from_fallout():
# Teleport to last safe tile, reset fallout state, then apply 1 HP damage via take_damage (no knockback)
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("get_last_safe_position_for_player"):
global_position = gw.get_last_safe_position_for_player(self)
fallout_state = false
fallout_scale_progress = 1.0
fallout_respawn_delay_timer = 0.0
scale = Vector2.ONE
rotation = 0.0
velocity = Vector2.ZERO
fallout_respawn_stun_timer = FALLOUT_RESPAWN_STUN_DURATION
_set_animation("IDLE")
# Apply damage via take_damage (shows damage number, sound, etc.) but with no knockback
take_damage(FALLOUT_RESPAWN_HP_PENALTY, global_position, false, false, true)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_respawn_from_fallout", [global_position])
func _respawn(): func _respawn():
print(name, " respawning!") print(name, " respawning!")
was_revived = false was_revived = false
@@ -6684,6 +6829,7 @@ func _respawn():
# Re-enable collision in case it was disabled while being carried # Re-enable collision in case it was disabled while being carried
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(2, true)
set_collision_mask_value(7, true) # Re-enable wall collision! set_collision_mask_value(7, true) # Re-enable wall collision!
# Reset health and state # Reset health and state
@@ -6815,9 +6961,10 @@ func _force_holder_to_drop_local(holder_name: String):
holder.grab_offset = Vector2.ZERO holder.grab_offset = Vector2.ZERO
holder.push_axis = Vector2.ZERO holder.push_axis = Vector2.ZERO
# Re-enable collision on dropped player # Re-enable collision on dropped player (layer 1, mask 1|2|64)
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(2, true)
set_collision_mask_value(7, true) # Re-enable wall collision! set_collision_mask_value(7, true) # Re-enable wall collision!
else: else:
print(" ✗ held_object doesn't match self") print(" ✗ held_object doesn't match self")
@@ -6936,14 +7083,43 @@ func _sync_death():
_apply_death_visual() _apply_death_visual()
return return
@rpc("any_peer", "reliable")
func _sync_fallout_start(tile_center_pos: Vector2):
# Other clients: start fallout sink visuals; ignore if this player is being held (immune to fallout)
if not is_multiplayer_authority():
if is_being_held:
return
global_position = tile_center_pos
fallout_state = true
fallout_scale_progress = 1.0
fallout_respawn_delay_timer = 0.0
velocity = Vector2.ZERO
current_direction = Direction.DOWN
facing_direction_vector = Vector2.DOWN
_set_animation("FALL")
@rpc("any_peer", "reliable")
func _sync_respawn_from_fallout(safe_pos: Vector2):
if not is_multiplayer_authority():
global_position = safe_pos
fallout_state = false
fallout_scale_progress = 1.0
fallout_respawn_delay_timer = 0.0
scale = Vector2.ONE
rotation = 0.0
velocity = Vector2.ZERO
fallout_respawn_stun_timer = FALLOUT_RESPAWN_STUN_DURATION
_set_animation("IDLE")
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_respawn(spawn_pos: Vector2): func _sync_respawn(spawn_pos: Vector2):
if not is_multiplayer_authority(): if not is_multiplayer_authority():
# being_held_by already cleared via RPC in _die() # being_held_by already cleared via RPC in _die()
# Holder already dropped us via _force_holder_to_drop RPC # Holder already dropped us via _force_holder_to_drop RPC
# Re-enable collision in case it was disabled while being carried # Re-enable collision in case it was disabled while being carried (layer 1, mask 1|2|64)
set_collision_layer_value(1, true) set_collision_layer_value(1, true)
set_collision_mask_value(1, true) set_collision_mask_value(1, true)
set_collision_mask_value(2, true)
set_collision_mask_value(7, true) # Re-enable wall collision! set_collision_mask_value(7, true) # Re-enable wall collision!
# Reset health and state # Reset health and state