fix spider bat boss alittle

This commit is contained in:
2026-02-06 02:49:58 +01:00
parent 9b8d84357f
commit fa7e969363
86 changed files with 4319 additions and 763 deletions

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://d0tknj36n3vwo"
path="res://.godot/imported/spawn_spider.wav-8b3babbab3c4ba911667834ca652655e.sample"
[deps]
source_file="res://assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav"
dest_files=["res://.godot/imported/spawn_spider.wav-8b3babbab3c4ba911667834ca652655e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c72t6mx4oqep6"
path="res://.godot/imported/webbed.wav-7454b773fd83c25e717dd593b6e03e5d.sample"
[deps]
source_file="res://assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav"
dest_files=["res://.godot/imported/webbed.wav-7454b773fd83c25e717dd593b6e03e5d.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dvvw4jhpj0ba"
path="res://.godot/imported/Dungeon Teleporting.wav-9c15e87c6625a75b2c5ae70765a0dfc5.sample"
[deps]
source_file="res://assets/audio/sfx/z3/Dungeon Teleporting.wav"
dest_files=["res://.godot/imported/Dungeon Teleporting.wav-9c15e87c6625a75b2c5ae70765a0dfc5.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://ya85wjb2c2jq"
path="res://.godot/imported/Hidden Treasure.wav-4a722a48442d87d1f342ba7690322fd0.sample"
[deps]
source_file="res://assets/audio/sfx/z3/Hidden Treasure.wav"
dest_files=["res://.godot/imported/Hidden Treasure.wav-4a722a48442d87d1f342ba7690322fd0.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c62cldelcfne2"
path="res://.godot/imported/LA_Dungeon_Teleport_Appear.wav-54a258164e247f88194203626b8a7227.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav"
dest_files=["res://.godot/imported/LA_Dungeon_Teleport_Appear.wav-54a258164e247f88194203626b8a7227.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b5eb43738ubhr"
path="res://.godot/imported/LA_Enemy_Fall.wav-5c1f126f97d1f1e5329cf7967629c757.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Enemy_Fall.wav"
dest_files=["res://.godot/imported/LA_Enemy_Fall.wav-5c1f126f97d1f1e5329cf7967629c757.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://csrrj2vrkbsqj"
path="res://.godot/imported/LA_Enemy_Jump.wav-14c628b999ac2f732bb16c252c8ae295.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Enemy_Jump.wav"
dest_files=["res://.godot/imported/LA_Enemy_Jump.wav-14c628b999ac2f732bb16c252c8ae295.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bqt1nwobb8ox7"
path="res://.godot/imported/LA_Fanfare_Item_Extended.wav-0b55b761c7da9e2f8b0bc63e17d54474.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav"
dest_files=["res://.godot/imported/LA_Fanfare_Item_Extended.wav-0b55b761c7da9e2f8b0bc63e17d54474.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://vxjjcuiam777"
path="res://.godot/imported/LA_Get_Item2.wav-ab9a88eeed38824f4ca082f2325efa2c.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Get_Item2.wav"
dest_files=["res://.godot/imported/LA_Get_Item2.wav-ab9a88eeed38824f4ca082f2325efa2c.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cjvqt1udp5kxq"
path="res://.godot/imported/LA_Ground_Crumble.wav-272cd2e57105c587e86ebefa52d1f6be.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Ground_Crumble.wav"
dest_files=["res://.godot/imported/LA_Ground_Crumble.wav-272cd2e57105c587e86ebefa52d1f6be.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dfel4odf6r138"
path="res://.godot/imported/LA_Link_Fall.wav-65086e00cfbb51f054cd9689fb280393.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Link_Fall.wav"
dest_files=["res://.godot/imported/LA_Link_Fall.wav-65086e00cfbb51f054cd9689fb280393.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://ckb3vyfwwmij"
path="res://.godot/imported/LA_NightmareShadows_Move.wav-2885ed8c855dee177e45a85e898c3e47.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_NightmareShadows_Move.wav"
dest_files=["res://.godot/imported/LA_NightmareShadows_Move.wav-2885ed8c855dee177e45a85e898c3e47.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cx1xe4yldwhfv"
path="res://.godot/imported/LA_Sword_Charge.wav-114f2c20f427b0b1b9b9d3ce1a114cce.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Charge.wav"
dest_files=["res://.godot/imported/LA_Sword_Charge.wav-114f2c20f427b0b1b9b9d3ce1a114cce.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://tyi5kdiw0hwg"
path="res://.godot/imported/LA_Sword_Slash1.wav-d975919bfdc80e3083b3a623890f3985.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Slash1.wav"
dest_files=["res://.godot/imported/LA_Sword_Slash1.wav-d975919bfdc80e3083b3a623890f3985.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b2jo6lccqgma0"
path="res://.godot/imported/LA_Sword_Slash2.wav-2eb425bb94535ebab29cf42cfeb8998b.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Slash2.wav"
dest_files=["res://.godot/imported/LA_Sword_Slash2.wav-2eb425bb94535ebab29cf42cfeb8998b.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dr7fd80esg2c7"
path="res://.godot/imported/LA_Sword_Slash3.wav-1b3736f2c19628deb55c8c6e31aadba6.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Slash3.wav"
dest_files=["res://.godot/imported/LA_Sword_Slash3.wav-1b3736f2c19628deb55c8c6e31aadba6.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cslploogfk2cw"
path="res://.godot/imported/LA_Sword_Slash4.wav-c78036997283570bbe39d2a5bcb7fddc.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Slash4.wav"
dest_files=["res://.godot/imported/LA_Sword_Slash4.wav-c78036997283570bbe39d2a5bcb7fddc.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://drny5i166dlhu"
path="res://.godot/imported/LA_Sword_Spin.wav-93bb3cc2ac6f0392102328bc5bae47f6.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Spin.wav"
dest_files=["res://.godot/imported/LA_Sword_Spin.wav-93bb3cc2ac6f0392102328bc5bae47f6.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c38uq1q83ltw1"
path="res://.godot/imported/LA_Sword_Tap.wav-c15f5bbfeaa5b3212f5826ca726da720.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Tap.wav"
dest_files=["res://.godot/imported/LA_Sword_Tap.wav-c15f5bbfeaa5b3212f5826ca726da720.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c3b8n1etek12p"
path="res://.godot/imported/LA_Sword_Tap_Bombable.wav-e1a5a82794449acf4e83d10d90551fe8.sample"
[deps]
source_file="res://assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav"
dest_files=["res://.godot/imported/LA_Sword_Tap_Bombable.wav-e1a5a82794449acf4e83d10d90551fe8.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,27 @@
[gd_scene format=3 uid="uid://ci7w4k6w75p8s"]
[ext_resource type="Script" uid="uid://bibyqdhticm5i" path="res://scripts/attack_web_shot.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[ext_resource type="AudioStreamWAV" path="res://assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav" id="3_webbed"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_web"]
size = Vector2(8, 8)
[node name="AttackWebShot" type="Area2D" unique_id=38090342]
collision_layer = 8
collision_mask = 1
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1512354021]
centered = true
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 568
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=817117840]
shape = SubResource("RectangleShape2D_web")
[node name="SfxWebbed" type="AudioStreamPlayer2D" parent="." unique_id=817117841]
stream = ExtResource("3_webbed")
bus = &"Sfx"

View File

@@ -4,19 +4,8 @@
[ext_resource type="Script" uid="uid://c00num5rkqm5l" path="res://scripts/boss_room_test.gd" id="1_qh2jl"] [ext_resource type="Script" uid="uid://c00num5rkqm5l" path="res://scripts/boss_room_test.gd" id="1_qh2jl"]
[ext_resource type="PackedScene" uid="uid://cxk4tjias8r18" path="res://scenes/torch_wall.tscn" id="2_qh2jl"] [ext_resource type="PackedScene" uid="uid://cxk4tjias8r18" path="res://scenes/torch_wall.tscn" id="2_qh2jl"]
[ext_resource type="PackedScene" uid="uid://cxfvw8y7jqn2p" path="res://scenes/player.tscn" id="3_bbgrj"] [ext_resource type="PackedScene" uid="uid://cxfvw8y7jqn2p" path="res://scenes/player.tscn" id="3_bbgrj"]
[ext_resource type="Texture2D" uid="uid://dnqqb2jw6qk5c" path="res://assets/gfx/enemies/boss/SPIDERBATNY.png" id="5_bbgrj"]
[ext_resource type="PackedScene" uid="uid://dlpxvxijydpob" path="res://scenes/fire.tscn" id="6_fwyy1"] [ext_resource type="PackedScene" uid="uid://dlpxvxijydpob" path="res://scenes/fire.tscn" id="6_fwyy1"]
[ext_resource type="PackedScene" uid="uid://kmlhn1iaceg8" path="res://scenes/boss_spider_bat.tscn" id="6_w00nx"]
[sub_resource type="Gradient" id="Gradient_bbgrj"]
colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_fwyy1"]
gradient = SubResource("Gradient_bbgrj")
width = 56
height = 8
fill = 1
fill_from = Vector2(0.50427353, 0.53846157)
fill_to = Vector2(1, 1)
[node name="BossRoomTest" type="Node2D" unique_id=1788886249] [node name="BossRoomTest" type="Node2D" unique_id=1788886249]
script = ExtResource("1_qh2jl") script = ExtResource("1_qh2jl")
@@ -51,7 +40,7 @@ position = Vector2(323, 352)
rotation = 3.1415927 rotation = 3.1415927
[node name="TorchWall4" parent="Environment" unique_id=600381662 instance=ExtResource("2_qh2jl")] [node name="TorchWall4" parent="Environment" unique_id=600381662 instance=ExtResource("2_qh2jl")]
position = Vector2(421, 351) position = Vector2(396, 351)
rotation = 3.1415927 rotation = 3.1415927
[node name="Entities" type="Node2D" parent="." unique_id=1341597062] [node name="Entities" type="Node2D" parent="." unique_id=1341597062]
@@ -126,11 +115,5 @@ light_mask = 1048575
visibility_layer = 1048575 visibility_layer = 1048575
color = Color(0.4140625, 0.4140625, 0.4140625, 1) color = Color(0.4140625, 0.4140625, 0.4140625, 1)
[node name="Spiderbatny" type="Sprite2D" parent="." unique_id=1820994324] [node name="BossSpiderBat" parent="." unique_id=673326165 instance=ExtResource("6_w00nx")]
position = Vector2(362, 221) position = Vector2(362, 221)
texture = ExtResource("5_bbgrj")
[node name="Sprite2D" type="Sprite2D" parent="Spiderbatny" unique_id=242364332]
z_index = -1
position = Vector2(0, 35)
texture = SubResource("GradientTexture2D_fwyy1")

View File

@@ -0,0 +1,442 @@
[gd_scene format=3 uid="uid://kmlhn1iaceg8"]
[ext_resource type="Script" uid="uid://bgdwn43m2yrtl" path="res://scripts/boss_spider_bat.gd" id="1_kwh6a"]
[ext_resource type="Texture2D" uid="uid://fevkanam8r2s" path="res://assets/gfx/enemies/boss/SpiderBat/flying_down.png" id="1_m3p0g"]
[ext_resource type="Texture2D" uid="uid://cr5mc326wpob3" path="res://assets/gfx/enemies/boss/SpiderBat/down_right.png" id="3_ht382"]
[ext_resource type="Texture2D" uid="uid://du3q527l26q1d" path="res://assets/gfx/enemies/boss/SpiderBat/Spiderbat death.png" id="3_ygtif"]
[ext_resource type="Texture2D" uid="uid://dsb72u2au1rkq" path="res://assets/gfx/enemies/boss/SpiderBat/right.png" id="4_ygtif"]
[ext_resource type="AudioStream" uid="uid://beeix80itbwb5" path="res://assets/audio/sfx/wizard/animevox/slash_1769364693031.wav" id="5_slash"]
[ext_resource type="AudioStream" uid="uid://d0tknj36n3vwo" path="res://assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav" id="6_spawn"]
[sub_resource type="Gradient" id="Gradient_bbgrj"]
colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_fwyy1"]
gradient = SubResource("Gradient_bbgrj")
width = 56
height = 8
fill = 1
fill_from = Vector2(0.50427353, 0.53846157)
fill_to = Vector2(1, 1)
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_m3p0g"]
radius = 9.0
[sub_resource type="Animation" id="Animation_m3p0g"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("1_m3p0g")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [5]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_hkjy0"]
resource_name = "die"
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.048109833, 0.08808845, 0.14907283, 0.20124832),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 1,
"values": [0, 2, 4, 6, 9]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("3_ygtif")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [10]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_ow570"]
resource_name = "down"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.08131247, 0.16262487, 0.30153358),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [1, 2, 3, 2]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("1_m3p0g")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [5]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_kwh6a"]
resource_name = "down_left"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0.0006776033, 0.08131247, 0.16126966, 0.30153358),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("3_ht382")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [4]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(-1, 1)]
}
[sub_resource type="Animation" id="Animation_5kw4h"]
resource_name = "down_right"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0.0006776033, 0.08131247, 0.16126966, 0.30153358),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("3_ht382")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [4]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="Animation" id="Animation_ht382"]
resource_name = "left"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.06301712, 0.15720406, 0.2893367),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [1, 2, 0, 1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("4_ygtif")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [4]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(-1, 1)]
}
[sub_resource type="Animation" id="Animation_dbivb"]
resource_name = "right"
length = 0.4
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Spiderbatny:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.06301712, 0.15720406, 0.2893367),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [1, 2, 0, 1]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("Spiderbatny:texture")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("4_ygtif")]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("Spiderbatny:hframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [4]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("Spiderbatny:scale")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1, 1)]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_o02h7"]
_data = {
&"RESET": SubResource("Animation_m3p0g"),
&"die": SubResource("Animation_hkjy0"),
&"down": SubResource("Animation_ow570"),
&"down_left": SubResource("Animation_kwh6a"),
&"down_right": SubResource("Animation_5kw4h"),
&"left": SubResource("Animation_ht382"),
&"right": SubResource("Animation_dbivb")
}
[sub_resource type="CircleShape2D" id="CircleShape2D_ht382"]
radius = 202.02228
[node name="BossSpiderBat" type="CharacterBody2D" unique_id=673326165]
script = ExtResource("1_kwh6a")
[node name="Spiderbatny" type="Sprite2D" parent="." unique_id=572346446]
texture = ExtResource("1_m3p0g")
hframes = 5
frame = 1
[node name="Sprite2D" type="Sprite2D" parent="Spiderbatny" unique_id=1095428481]
position = Vector2(0, 35)
texture = SubResource("GradientTexture2D_fwyy1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1518243670]
position = Vector2(0, 27)
rotation = 1.5707964
shape = SubResource("CapsuleShape2D_m3p0g")
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1762485835]
libraries/ = SubResource("AnimationLibrary_o02h7")
autoplay = &"down"
[node name="ActivationArea" type="Area2D" parent="." unique_id=1681865045]
[node name="CollisionShape2D" type="CollisionShape2D" parent="ActivationArea" unique_id=1459593944]
position = Vector2(0, 26)
shape = SubResource("CircleShape2D_ht382")
debug_color = Color(0.70196074, 0.4646213, 0.36891928, 0.41960785)
[node name="SfxWebShot" type="AudioStreamPlayer2D" parent="." unique_id=1681865046]
stream = ExtResource("5_slash")
bus = &"Sfx"
[node name="SfxSpawnSpider" type="AudioStreamPlayer2D" parent="." unique_id=1681865047]
stream = ExtResource("6_spawn")
bus = &"Sfx"
[connection signal="body_shape_entered" from="ActivationArea" to="." method="_on_activation_area_body_shape_entered"]

View File

@@ -0,0 +1,31 @@
[gd_scene format=3 uid="uid://c3ogf878jdfql"]
[ext_resource type="Script" uid="uid://b45h84vbq3jw" path="res://scripts/detected_effect.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
[sub_resource type="Gradient" id="Gradient_blue"]
offsets = PackedFloat32Array(0.5, 0.85)
colors = PackedColorArray(1, 1, 1, 1, 0.4, 0.5, 0.9, 0)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_blue"]
gradient = SubResource("Gradient_blue")
fill = 1
fill_from = Vector2(0.5, 0.5)
fill_to = Vector2(0.5, 0)
[node name="DetectedEffect" type="Node2D" unique_id=1116195340]
z_index = 5
script = ExtResource("1_script")
[node name="FxSprite" type="Sprite2D" parent="." unique_id=1691079974]
z_index = 1
texture = ExtResource("2_tex")
hframes = 105
vframes = 79
frame = 169
[node name="DetectLight" type="PointLight2D" parent="." unique_id=1040746394]
color = Color(0.35, 0.5, 0.95, 1)
energy = 0.9
texture = SubResource("GradientTexture2D_blue")
texture_scale = 0.6

View File

@@ -87,6 +87,9 @@ radius = 5.0
[sub_resource type="CircleShape2D" id="CircleShape2D_ptw5w"] [sub_resource type="CircleShape2D" id="CircleShape2D_ptw5w"]
radius = 58.30952 radius = 58.30952
[sub_resource type="CircleShape2D" id="CircleShape2D_detect"]
radius = 50.0
[node name="EnemyHand" type="CharacterBody2D" unique_id=512887809] [node name="EnemyHand" type="CharacterBody2D" unique_id=512887809]
collision_mask = 64 collision_mask = 64
script = ExtResource("1_hqcsv") script = ExtResource("1_hqcsv")
@@ -103,6 +106,13 @@ libraries/ = SubResource("AnimationLibrary_ptw5w")
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=448410993] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=448410993]
shape = SubResource("CircleShape2D_lpach") shape = SubResource("CircleShape2D_lpach")
[node name="DetectionArea" type="Area2D" parent="." unique_id=1888073969]
collision_layer = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="DetectionArea" unique_id=1403525747]
shape = SubResource("CircleShape2D_detect")
debug_color = Color(0.54, 0.05, 0.7, 0.42)
[node name="EmergeArea" type="Area2D" parent="." unique_id=1688073969] [node name="EmergeArea" type="Area2D" parent="." unique_id=1688073969]
collision_layer = 0 collision_layer = 0
@@ -122,6 +132,7 @@ collision_layer = 0
shape = SubResource("CircleShape2D_ptw5w") shape = SubResource("CircleShape2D_ptw5w")
debug_color = Color(0.70196074, 0.6745404, 0.69139016, 0.41960785) debug_color = Color(0.70196074, 0.6745404, 0.69139016, 0.41960785)
[connection signal="body_entered" from="DetectionArea" to="." method="_on_detection_area_body_entered"]
[connection signal="body_entered" from="EmergeArea" to="." method="_on_emerge_area_body_entered"] [connection signal="body_entered" from="EmergeArea" to="." method="_on_emerge_area_body_entered"]
[connection signal="body_entered" from="GrabPlayerArea" to="." method="_on_grab_player_area_body_entered"] [connection signal="body_entered" from="GrabPlayerArea" to="." method="_on_grab_player_area_body_entered"]
[connection signal="body_entered" from="PlayerInterestArea" to="." method="_on_player_interest_area_body_entered"] [connection signal="body_entered" from="PlayerInterestArea" to="." method="_on_player_interest_area_body_entered"]

View File

@@ -340,6 +340,7 @@ vframes = 8
[node name="Sprite2DShield" type="Sprite2D" parent="." unique_id=470468744] [node name="Sprite2DShield" type="Sprite2D" parent="." unique_id=470468744]
visible = false visible = false
material = SubResource("ShaderMaterial_shield") material = SubResource("ShaderMaterial_shield")
position = Vector2(0, -4)
texture = ExtResource("14_shield") texture = ExtResource("14_shield")
hframes = 35 hframes = 35
vframes = 8 vframes = 8
@@ -347,6 +348,7 @@ vframes = 8
[node name="Sprite2DShieldHolding" type="Sprite2D" parent="." unique_id=1318098286] [node name="Sprite2DShieldHolding" type="Sprite2D" parent="." unique_id=1318098286]
visible = false visible = false
material = SubResource("ShaderMaterial_shield") material = SubResource("ShaderMaterial_shield")
position = Vector2(0, -4)
texture = ExtResource("15_shieldh") texture = ExtResource("15_shieldh")
hframes = 35 hframes = 35
vframes = 8 vframes = 8

View File

@@ -2,6 +2,7 @@
[ext_resource type="Script" uid="uid://id0s5um3dac1" path="res://scripts/enemy_slime.gd" id="1"] [ext_resource type="Script" uid="uid://id0s5um3dac1" path="res://scripts/enemy_slime.gd" id="1"]
[ext_resource type="Texture2D" uid="uid://csr5k0etreqbf" path="res://assets/gfx/enemies/Slime.png" id="2"] [ext_resource type="Texture2D" uid="uid://csr5k0etreqbf" path="res://assets/gfx/enemies/Slime.png" id="2"]
[ext_resource type="AudioStream" uid="uid://csrrj2vrkbsqj" path="res://assets/audio/sfx/z3/LA_Enemy_Jump.wav" id="3_y5ckx"]
[sub_resource type="CircleShape2D" id="CircleShape2D_slime"] [sub_resource type="CircleShape2D" id="CircleShape2D_slime"]
radius = 6.0 radius = 6.0
@@ -26,3 +27,8 @@ hframes = 15
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=521937817] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=521937817]
shape = SubResource("CircleShape2D_slime") shape = SubResource("CircleShape2D_slime")
[node name="SfxJump" type="AudioStreamPlayer2D" parent="." unique_id=1770035153]
stream = ExtResource("3_y5ckx")
attenuation = 3.363586
bus = &"Sfx"

View File

@@ -0,0 +1,29 @@
[gd_scene format=3 uid="uid://b46vll6yphyl5"]
[ext_resource type="Script" uid="uid://7e0vssvq87hn" path="res://scripts/enemy_spider.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://bmogyono02pl3" path="res://assets/gfx/enemies/spider.png" id="2_tex"]
[sub_resource type="CircleShape2D" id="CircleShape2D_spider"]
radius = 4.0
[node name="EnemySpider" type="CharacterBody2D" unique_id=716780254]
collision_layer = 2
collision_mask = 65
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1167304991]
scale = Vector2(0.5, 0.5)
texture = ExtResource("2_tex")
hframes = 3
vframes = 3
[node name="Shadow" type="Sprite2D" parent="." unique_id=1051112006]
modulate = Color(0, 0, 0, 0.5)
z_index = -1
scale = Vector2(0.5, 0.5)
texture = ExtResource("2_tex")
hframes = 3
vframes = 3
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1330855476]
shape = SubResource("CircleShape2D_spider")

View File

@@ -185,6 +185,44 @@ horizontal_alignment = 1
layout_mode = 2 layout_mode = 2
texture_progress = ExtResource("4_hearts_filled") texture_progress = ExtResource("4_hearts_filled")
[node name="CenterBottom" type="MarginContainer" parent="."]
anchors_preset = 7
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 20.0
offset_top = -80.0
offset_right = -20.0
offset_bottom = 0.0
offset_bottom = -8.0
grow_horizontal = 2
grow_vertical = 0
theme = SubResource("Theme_standard_font")
[node name="VBoxBoss" type="VBoxContainer" parent="CenterBottom"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 4
[node name="LabelBossCenter" type="Label" parent="CenterBottom/VBoxBoss"]
layout_mode = 2
theme = SubResource("Theme_standard_font")
text = "BOSS"
horizontal_alignment = 1
[node name="ProgressBarBossHP" type="ProgressBar" parent="CenterBottom/VBoxBoss"]
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 24)
max_value = 400.0
value = 400.0
show_percentage = false
[node name="CenterTop" type="MarginContainer" parent="." unique_id=22752256] [node name="CenterTop" type="MarginContainer" parent="." unique_id=22752256]
anchors_preset = 5 anchors_preset = 5
anchor_left = 0.5 anchor_left = 0.5

View File

@@ -28,11 +28,16 @@
[ext_resource type="AudioStream" uid="uid://4ilddgc4lgyq" path="res://assets/audio/sfx/environment/crate/crash_table-04.wav" id="26_vfomk"] [ext_resource type="AudioStream" uid="uid://4ilddgc4lgyq" path="res://assets/audio/sfx/environment/crate/crash_table-04.wav" id="26_vfomk"]
[ext_resource type="AudioStream" uid="uid://c7kc0aw0wevah" path="res://assets/audio/sfx/environment/crate/wood_impact_break.mp3" id="27_2p257"] [ext_resource type="AudioStream" uid="uid://c7kc0aw0wevah" path="res://assets/audio/sfx/environment/crate/wood_impact_break.mp3" id="27_2p257"]
[ext_resource type="Texture2D" uid="uid://bknascfv4twmi" path="res://assets/gfx/smoke_puffs.png" id="28_2p257"] [ext_resource type="Texture2D" uid="uid://bknascfv4twmi" path="res://assets/gfx/smoke_puffs.png" id="28_2p257"]
[ext_resource type="AudioStream" uid="uid://vxjjcuiam777" path="res://assets/audio/sfx/z3/LA_Get_Item2.wav" id="29_getitem"]
[ext_resource type="AudioStream" path="res://assets/audio/sfx/z3/LA_Enemy_Fall.wav" id="30_fall"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_nyc8x"] [sub_resource type="CapsuleShape2D" id="CapsuleShape2D_nyc8x"]
radius = 4.0 radius = 4.0
height = 12.0 height = 12.0
[sub_resource type="CircleShape2D" id="CircleShape2D_detect"]
radius = 99.0
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_1u1k0"] [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_1u1k0"]
playback_mode = 1 playback_mode = 1
streams_count = 7 streams_count = 7
@@ -181,6 +186,13 @@ position = Vector2(0, 2)
rotation = -1.5707964 rotation = -1.5707964
shape = SubResource("CapsuleShape2D_nyc8x") shape = SubResource("CapsuleShape2D_nyc8x")
[node name="DetectionArea" type="Area2D" parent="." unique_id=1724772540]
collision_layer = 0
collision_mask = 1
[node name="CollisionShape2D" type="CollisionShape2D" parent="DetectionArea" unique_id=1724772541]
shape = SubResource("CircleShape2D_detect")
[node name="SfxShatter" type="AudioStreamPlayer2D" parent="." unique_id=785438237] [node name="SfxShatter" type="AudioStreamPlayer2D" parent="." unique_id=785438237]
stream = SubResource("AudioStreamRandomizer_1u1k0") stream = SubResource("AudioStreamRandomizer_1u1k0")
max_distance = 1187.0 max_distance = 1187.0
@@ -206,6 +218,18 @@ bus = &"Sfx"
[node name="SfxOpenChest" type="AudioStreamPlayer2D" parent="." unique_id=743332693] [node name="SfxOpenChest" type="AudioStreamPlayer2D" parent="." unique_id=743332693]
stream = SubResource("AudioStreamRandomizer_vfomk") stream = SubResource("AudioStreamRandomizer_vfomk")
[node name="SfxGetItemFromChest" type="AudioStreamPlayer2D" parent="." unique_id=1928374650]
stream = ExtResource("29_getitem")
attenuation = 7.0
panning_strength = 1.1
bus = &"Sfx"
[node name="SfxFall" type="AudioStreamPlayer2D" parent="." unique_id=1928374651]
stream = ExtResource("30_fall")
attenuation = 6.0
panning_strength = 1.12
bus = &"Sfx"
[node name="SfxDragRock" type="AudioStreamPlayer2D" parent="." unique_id=1895903195] [node name="SfxDragRock" type="AudioStreamPlayer2D" parent="." unique_id=1895903195]
stream = SubResource("AudioStreamRandomizer_2p257") stream = SubResource("AudioStreamRandomizer_2p257")
volume_db = -2.611 volume_db = -2.611
@@ -234,3 +258,4 @@ process_material = SubResource("ParticleProcessMaterial_ejwle")
wait_time = 0.07 wait_time = 0.07
[connection signal="timeout" from="DragParticles/TimerSmokeParticles" to="." method="_on_timer_smoke_particles_timeout"] [connection signal="timeout" from="DragParticles/TimerSmokeParticles" to="." method="_on_timer_smoke_particles_timeout"]
[connection signal="body_entered" from="DetectionArea" to="." method="_on_detection_area_body_entered"]

View File

@@ -11,6 +11,7 @@
[ext_resource type="AudioStream" uid="uid://b5xbv7s85sy5o" path="res://assets/audio/sfx/pickups/potion.mp3" id="7_eeo7l"] [ext_resource type="AudioStream" uid="uid://b5xbv7s85sy5o" path="res://assets/audio/sfx/pickups/potion.mp3" id="7_eeo7l"]
[ext_resource type="AudioStream" uid="uid://cnb376ah43nqi" path="res://assets/audio/sfx/pickups/bite-food-01.mp3" id="8_0tqa7"] [ext_resource type="AudioStream" uid="uid://cnb376ah43nqi" path="res://assets/audio/sfx/pickups/bite-food-01.mp3" id="8_0tqa7"]
[ext_resource type="AudioStream" uid="uid://bbnby1sso3f4v" path="res://assets/audio/sfx/pickups/bite-food-02.mp3" id="9_531sv"] [ext_resource type="AudioStream" uid="uid://bbnby1sso3f4v" path="res://assets/audio/sfx/pickups/bite-food-02.mp3" id="9_531sv"]
[ext_resource type="AudioStream" path="res://assets/audio/sfx/z3/LA_Enemy_Fall.wav" id="10_fall"]
[sub_resource type="Gradient" id="Gradient_1"] [sub_resource type="Gradient" id="Gradient_1"]
colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0) colors = PackedColorArray(0, 0, 0, 1, 0, 0, 0, 0)
@@ -106,5 +107,11 @@ stream = ExtResource("6_gyjv8")
[node name="SfxPotionCollect" type="AudioStreamPlayer2D" parent="." unique_id=1615824668] [node name="SfxPotionCollect" type="AudioStreamPlayer2D" parent="." unique_id=1615824668]
stream = ExtResource("7_eeo7l") stream = ExtResource("7_eeo7l")
[node name="SfxFall" type="AudioStreamPlayer2D" parent="." unique_id=1867176791]
stream = ExtResource("10_fall")
attenuation = 6.0
panning_strength = 1.12
bus = &"Sfx"
[node name="SfxBananaCollect" type="AudioStreamPlayer2D" parent="." unique_id=1763488179] [node name="SfxBananaCollect" type="AudioStreamPlayer2D" parent="." unique_id=1763488179]
stream = SubResource("AudioStreamRandomizer_37k03") stream = SubResource("AudioStreamRandomizer_37k03")

View File

@@ -50,6 +50,8 @@
[ext_resource type="AudioStream" uid="uid://cbbeyrdor7nyg" path="res://assets/audio/sfx/player/notice/lookout.mp3" id="48_6e8lb"] [ext_resource type="AudioStream" uid="uid://cbbeyrdor7nyg" path="res://assets/audio/sfx/player/notice/lookout.mp3" id="48_6e8lb"]
[ext_resource type="AudioStream" uid="uid://cs5ruoyq80yi4" path="res://assets/audio/sfx/player/notice/aha.mp3" id="49_2gdjj"] [ext_resource type="AudioStream" uid="uid://cs5ruoyq80yi4" path="res://assets/audio/sfx/player/notice/aha.mp3" id="49_2gdjj"]
[ext_resource type="AudioStream" uid="uid://bew7ciiygabaj" path="res://assets/audio/sfx/player/notice/whatdowehavehere.mp3" id="50_sc3ue"] [ext_resource type="AudioStream" uid="uid://bew7ciiygabaj" path="res://assets/audio/sfx/player/notice/whatdowehavehere.mp3" id="50_sc3ue"]
[ext_resource type="AudioStream" uid="uid://bqt1nwobb8ox7" path="res://assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav" id="51_fanfare"]
[ext_resource type="AudioStream" uid="uid://ya85wjb2c2jq" path="res://assets/audio/sfx/z3/Hidden Treasure.wav" id="52_sc3ue"]
[sub_resource type="Gradient" id="Gradient_wqfne"] [sub_resource type="Gradient" id="Gradient_wqfne"]
colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1)
@@ -474,6 +476,21 @@ tracks/0/keys = {
"values": [0] "values": [0]
} }
[sub_resource type="Animation" id="Animation_sc3ue"]
resource_name = "netted"
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("IncantationSprite:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [679]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"] [sub_resource type="AnimationLibrary" id="AnimationLibrary_2dvfe"]
_data = { _data = {
&"RESET": SubResource("Animation_t4otl"), &"RESET": SubResource("Animation_t4otl"),
@@ -484,7 +501,8 @@ _data = {
&"frost_ready": SubResource("Animation_frost_rdy"), &"frost_ready": SubResource("Animation_frost_rdy"),
&"healing_charging": SubResource("Animation_heal_ch"), &"healing_charging": SubResource("Animation_heal_ch"),
&"healing_ready": SubResource("Animation_heal_rdy"), &"healing_ready": SubResource("Animation_heal_rdy"),
&"idle": SubResource("Animation_hax0n") &"idle": SubResource("Animation_hax0n"),
&"netted": SubResource("Animation_sc3ue")
} }
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_u2ulf"] [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_u2ulf"]
@@ -870,26 +888,34 @@ bus = &"Sfx"
[node name="SfxBirdSound" type="AudioStreamPlayer2D" parent="." unique_id=1946085725] [node name="SfxBirdSound" type="AudioStreamPlayer2D" parent="." unique_id=1946085725]
stream = ExtResource("47_mx1m4") stream = ExtResource("47_mx1m4")
volume_db = -13.255 volume_db = -9.289
attenuation = 3.2490087 attenuation = 3.2490087
panning_strength = 1.12 panning_strength = 1.12
bus = &"Sfx" bus = &"Sfx"
[node name="SfxLookOut" type="AudioStreamPlayer2D" parent="." unique_id=1177750193] [node name="SfxLookOut" type="AudioStreamPlayer2D" parent="." unique_id=1177750193]
stream = ExtResource("48_6e8lb") stream = ExtResource("48_6e8lb")
volume_db = 0.881 volume_db = 8.813
max_distance = 1138.0 max_distance = 1138.0
attenuation = 7.999997 attenuation = 5.856343
panning_strength = 1.04 panning_strength = 1.04
bus = &"Sfx"
[node name="SfxAhaa" type="AudioStreamPlayer2D" parent="." unique_id=1556952538] [node name="SfxAhaa" type="AudioStreamPlayer2D" parent="." unique_id=1556952538]
stream = SubResource("AudioStreamRandomizer_lxlsd") stream = SubResource("AudioStreamRandomizer_lxlsd")
volume_db = 8.053
max_distance = 1496.0 max_distance = 1496.0
attenuation = 6.062864 attenuation = 6.062864
panning_strength = 1.13 panning_strength = 1.13
bus = &"Sfx"
[node name="SfxDeny2" type="AudioStreamPlayer2D" parent="." unique_id=1127340261] [node name="SfxDeny2" type="AudioStreamPlayer2D" parent="." unique_id=1127340261]
stream = ExtResource("45_g5jhy") stream = ExtResource("45_g5jhy")
max_distance = 1619.0 max_distance = 1619.0
[node name="SfxLevelUp" type="AudioStreamPlayer2D" parent="." unique_id=1127340262]
stream = ExtResource("51_fanfare")
bus = &"Sfx"
[node name="SfxSecretFound" type="AudioStreamPlayer" parent="." unique_id=485278181]
stream = ExtResource("52_sc3ue")
volume_db = 0.882
bus = &"Sfx"

View File

@@ -4,6 +4,11 @@
[ext_resource type="Texture2D" uid="uid://b6eeio3gm7d4u" path="res://assets/gfx/traps/Floor_Lance.png" id="2_aucmg"] [ext_resource type="Texture2D" uid="uid://b6eeio3gm7d4u" path="res://assets/gfx/traps/Floor_Lance.png" id="2_aucmg"]
[ext_resource type="AudioStream" uid="uid://raqmpvp1vj04" path="res://assets/audio/sfx/traps/activate.mp3" id="3_tk2q1"] [ext_resource type="AudioStream" uid="uid://raqmpvp1vj04" path="res://assets/audio/sfx/traps/activate.mp3" id="3_tk2q1"]
[ext_resource type="AudioStream" uid="uid://dxy2phfh0ojot" path="res://assets/audio/sfx/player/dodge/Dodge.mp3" id="4_1sb0t"] [ext_resource type="AudioStream" uid="uid://dxy2phfh0ojot" path="res://assets/audio/sfx/player/dodge/Dodge.mp3" id="4_1sb0t"]
[ext_resource type="AudioStream" uid="uid://fm6hrpckfknc" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_03.wav" id="5_jl22o"]
[ext_resource type="AudioStream" uid="uid://be3uspidyqm3x" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_04.wav" id="6_fp550"]
[ext_resource type="AudioStream" uid="uid://dvttykynr671m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_05.wav" id="7_1ywii"]
[ext_resource type="AudioStream" uid="uid://sejnuklu653m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_06.wav" id="8_2uf36"]
[ext_resource type="AudioStream" uid="uid://vxjjcuiam777" path="res://assets/audio/sfx/z3/LA_Get_Item2.wav" id="9_disarm"]
[sub_resource type="Animation" id="Animation_tk2q1"] [sub_resource type="Animation" id="Animation_tk2q1"]
length = 0.001 length = 0.001
@@ -84,6 +89,14 @@ radius = 17.117243
[sub_resource type="CircleShape2D" id="CircleShape2D_62q8x"] [sub_resource type="CircleShape2D" id="CircleShape2D_62q8x"]
radius = 99.0202 radius = 99.0202
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_crw1k"]
random_pitch = 1.0312661
streams_count = 4
stream_0/stream = ExtResource("5_jl22o")
stream_1/stream = ExtResource("6_fp550")
stream_2/stream = ExtResource("7_1ywii")
stream_3/stream = ExtResource("8_2uf36")
[node name="Trap" type="Node2D" unique_id=131165873] [node name="Trap" type="Node2D" unique_id=131165873]
script = ExtResource("1_62q8x") script = ExtResource("1_62q8x")
@@ -128,6 +141,17 @@ attenuation = 1.9318731
bus = &"Sfx" bus = &"Sfx"
[node name="SfxDisarming" type="AudioStreamPlayer2D" parent="." unique_id=920322213] [node name="SfxDisarming" type="AudioStreamPlayer2D" parent="." unique_id=920322213]
stream = SubResource("AudioStreamRandomizer_crw1k")
volume_db = -5.904
max_distance = 1860.0
attenuation = 1.9318731
panning_strength = 1.08
bus = &"Sfx"
[node name="SfxDisarmSuccess" type="AudioStreamPlayer2D" parent="." unique_id=920322214]
stream = ExtResource("9_disarm")
attenuation = 7.0
panning_strength = 1.2
bus = &"Sfx" bus = &"Sfx"
[connection signal="body_shape_entered" from="ActivationArea" to="." method="_on_activation_area_body_shape_entered"] [connection signal="body_shape_entered" from="ActivationArea" to="." method="_on_activation_area_body_shape_entered"]

View File

@@ -20,6 +20,7 @@ const DIR_ANIMATIONS: Array = [
var elapsed_time: float = 0.0 var elapsed_time: float = 0.0
var player_owner: Node = null var player_owner: Node = null
var hit_targets: Dictionary = {} var hit_targets: Dictionary = {}
var _cut_web_rpc_sent: bool = false
var damage_effect_axe_scene: PackedScene = preload("res://scenes/damage_effect_axe.tscn") var damage_effect_axe_scene: PackedScene = preload("res://scenes/damage_effect_axe.tscn")
@@ -31,6 +32,9 @@ func _ready() -> void:
if hit_area: if hit_area:
if not hit_area.body_entered.is_connected(_on_damage_area_body_entered): if not hit_area.body_entered.is_connected(_on_damage_area_body_entered):
hit_area.body_entered.connect(_on_damage_area_body_entered) hit_area.body_entered.connect(_on_damage_area_body_entered)
if not hit_area.area_entered.is_connected(_on_damage_area_area_entered):
hit_area.area_entered.connect(_on_damage_area_area_entered)
hit_area.collision_mask = hit_area.collision_mask | 8
if has_node("AttackSwosh"): if has_node("AttackSwosh"):
$AttackSwosh.play() $AttackSwosh.play()
@@ -60,9 +64,20 @@ func _process(delta: float) -> void:
elapsed_time += delta elapsed_time += delta
if player_owner and is_instance_valid(player_owner): if player_owner and is_instance_valid(player_owner):
global_position = player_owner.global_position global_position = player_owner.global_position
# So other player can free netted teammate: notify server to cut web
if not _cut_web_rpc_sent and multiplayer.has_multiplayer_peer() and not multiplayer.is_server() and elapsed_time >= 0.05:
_cut_web_rpc_sent = true
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("request_cut_web"):
gw.request_cut_web.rpc_id(1, global_position.x, global_position.y, 32.0, player_owner.get_multiplayer_authority())
if elapsed_time >= LIFETIME: if elapsed_time >= LIFETIME:
queue_free() queue_free()
func _on_damage_area_area_entered(area: Area2D) -> void:
if area.has_method("cut_by_attack") and area.get("state") == "hit_player":
if player_owner and is_instance_valid(player_owner) and player_owner != area.get("hit_player"):
area.cut_by_attack(player_owner)
func _on_damage_area_body_entered(body: Node2D) -> void: func _on_damage_area_body_entered(body: Node2D) -> void:
if body == player_owner: if body == player_owner:
return return
@@ -90,6 +105,9 @@ func _on_damage_area_body_entered(body: Node2D) -> void:
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false) game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
else: else:
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false) game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
# Client: show damage number locally (boss/enemy won't show it on our view otherwise)
if game_world and game_world.has_method("show_damage_number_at_position") and not multiplayer.is_server():
game_world.show_damage_number_at_position(body.global_position, damage, false)
else: else:
var eid = body.get_multiplayer_authority() var eid = body.get_multiplayer_authority()
if eid != 0: if eid != 0:

View File

@@ -14,6 +14,7 @@ var punch_direction: Vector2 = Vector2.RIGHT
var player_owner: Node = null var player_owner: Node = null
var hit_targets: Dictionary = {} var hit_targets: Dictionary = {}
var elapsed: float = 0.0 var elapsed: float = 0.0
var _cut_web_rpc_sent: bool = false
var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn") var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn")
@@ -23,8 +24,12 @@ var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect
func _ready() -> void: func _ready() -> void:
if has_node("SfxSwosh"): if has_node("SfxSwosh"):
$SfxSwosh.play() $SfxSwosh.play()
if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): if hit_area:
if not hit_area.body_entered.is_connected(_on_body_entered):
hit_area.body_entered.connect(_on_body_entered) hit_area.body_entered.connect(_on_body_entered)
if not hit_area.area_entered.is_connected(_on_area_entered):
hit_area.area_entered.connect(_on_area_entered)
hit_area.collision_mask = hit_area.collision_mask | 8
if sprite and PUNCH_FRAMES.size() > 0: if sprite and PUNCH_FRAMES.size() > 0:
sprite.frame = PUNCH_FRAMES[0] sprite.frame = PUNCH_FRAMES[0]
@@ -46,10 +51,21 @@ func _process(delta: float) -> void:
if sprite and PUNCH_FRAMES.size() > 0: if sprite and PUNCH_FRAMES.size() > 0:
var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1) var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1)
sprite.frame = PUNCH_FRAMES[frame_idx] sprite.frame = PUNCH_FRAMES[frame_idx]
# So other player can free netted teammate: notify server to cut web (web may only exist on server)
if not _cut_web_rpc_sent and multiplayer.has_multiplayer_peer() and not multiplayer.is_server() and player_owner and is_instance_valid(player_owner) and elapsed >= 0.03:
_cut_web_rpc_sent = true
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("request_cut_web"):
gw.request_cut_web.rpc_id(1, global_position.x, global_position.y, 28.0, player_owner.get_multiplayer_authority())
if elapsed >= lifetime: if elapsed >= lifetime:
hit_area.set_deferred("monitoring", false) hit_area.set_deferred("monitoring", false)
queue_free() queue_free()
func _on_area_entered(area: Area2D) -> void:
if area.has_method("cut_by_attack") and area.get("state") == "hit_player":
if player_owner and is_instance_valid(player_owner) and player_owner != area.get("hit_player"):
area.cut_by_attack(player_owner)
func _on_body_entered(body: Node2D) -> void: func _on_body_entered(body: Node2D) -> void:
if body == player_owner: if body == player_owner:
return return
@@ -77,6 +93,9 @@ func _on_body_entered(body: Node2D) -> void:
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false) game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
else: else:
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false) game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
# Client: show damage number locally (boss/enemy won't show it on our view otherwise)
if game_world and game_world.has_method("show_damage_number_at_position") and not multiplayer.is_server():
game_world.show_damage_number_at_position(body.global_position, damage, false)
else: else:
var enemy_peer_id = body.get_multiplayer_authority() var enemy_peer_id = body.get_multiplayer_authority()
if enemy_peer_id != 0: if enemy_peer_id != 0:
@@ -92,7 +111,10 @@ func _on_body_entered(body: Node2D) -> void:
return return
# Interactables with health (boxes, etc.) - small damage # Interactables with health (boxes, etc.) - small damage
# Hidden chests must not be hit until detected (no damage, no effect)
if "health" in body: if "health" in body:
if body.get("object_type") == "Chest" and body.get("is_hidden") and not body.get("is_detected"):
return
if has_node("SfxImpact"): if has_node("SfxImpact"):
$SfxImpact.play() $SfxImpact.play()
body.health -= damage body.health -= damage

View File

@@ -0,0 +1,172 @@
extends Area2D
# Web shot from boss: frame 568 in shade_spell_effects (105h x 79v). 8x8 collision.
# Flies to target; on hit player or arrival: animate 568->573.
# If arrived at point: stay 30s, fade out at end (net doesn't stick during fade).
# If hit player: player stuck 5s (frame 679 netted), fade last 0.3s; other player can cut net.
# Netted player cannot attack with main weapon.
var target_position: Vector2 = Vector2.ZERO
var speed: float = 140.0
var state: String = "flying" # flying | impact_anim | landed_net | hit_player
var impact_frame: int = 568
var impact_frame_end: int = 573
var landed_lifetime: float = 30.0
var landed_timer: float = 0.0
var fade_start: float = 4.0 # Start fade this many seconds before end
var fired_by_boss: Node = null
var hit_player: Node = null # When state == hit_player, the netted player
var netted_duration: float = 5.0
var netted_timer: float = 0.0
var netted_fade_start: float = 0.3
const TILE_SIZE: int = 16
const HFRAMES: int = 105
const VFRAMES: int = 79
@onready var sprite: Sprite2D = $Sprite2D
@onready var sfx_webbed: AudioStreamPlayer2D = $SfxWebbed
@onready var anim_timer: float = 0.0
var anim_frame: int = 568
func _ready() -> void:
# Collision: detect players (layer 1)
collision_layer = 0
collision_mask = 1
body_entered.connect(_on_body_entered)
anim_frame = impact_frame
if sprite:
sprite.hframes = HFRAMES
sprite.vframes = VFRAMES
sprite.frame = impact_frame
sprite.centered = true
func set_target(pos: Vector2) -> void:
target_position = pos
func set_fired_by_boss(boss: Node) -> void:
fired_by_boss = boss
func _process(delta: float) -> void:
if state == "flying":
var dir = (target_position - global_position).normalized()
var dist = global_position.distance_to(target_position)
if dist <= speed * delta:
global_position = target_position
_impact_at_point()
else:
global_position += dir * speed * delta
return
if state == "impact_anim":
anim_timer += delta
var frame_count = impact_frame_end - impact_frame + 1
var duration = 0.25
var t = clamp(anim_timer / duration, 0.0, 1.0)
var idx = impact_frame + int(t * (frame_count - 1))
if sprite:
sprite.frame = idx
if anim_timer >= duration:
state = "landed_net"
landed_timer = 0.0
collision_layer = 1 # Can be hit by other players to cut?
collision_mask = 1 # Detect players (for cutting / fade stick logic)
return
if state == "landed_net":
landed_timer += delta
# Fade during last fade_start seconds
if landed_timer >= landed_lifetime - fade_start:
var fade_t = (landed_timer - (landed_lifetime - fade_start)) / fade_start
if sprite:
sprite.modulate.a = 1.0 - fade_t
# During fade, net doesn't stick - no collision with player for stick
if landed_timer >= landed_lifetime:
queue_free()
return
if state == "hit_player":
netted_timer += delta
# Keep net at player position
if hit_player and is_instance_valid(hit_player):
global_position = hit_player.global_position
# Fade last 0.3s
if netted_timer >= netted_duration - netted_fade_start:
var fade_t = (netted_timer - (netted_duration - netted_fade_start)) / netted_fade_start
if sprite:
sprite.modulate.a = 1.0 - fade_t
if netted_timer >= netted_duration:
_release_netted_player()
queue_free()
else:
queue_free()
func _on_body_entered(body: Node2D) -> void:
if state == "flying":
if body.is_in_group("player"):
_impact_on_player(body)
return
# Reached target (or hit wall) - treat as impact at point
if global_position.distance_to(target_position) < 24.0:
_impact_at_point()
return
if state == "landed_net" and body.is_in_group("player"):
_impact_on_player(body)
func _impact_at_point() -> void:
state = "impact_anim"
anim_timer = 0.0
# Stop moving; play 568->573
collision_layer = 0
collision_mask = 0
# After anim, stay 30s then fade (handled in _process)
func _impact_on_player(player: Node2D) -> void:
if not player.is_in_group("player"):
return
# Already netted?
if "netted_by_web" in player and player.netted_by_web != null:
return
state = "hit_player"
hit_player = player
netted_timer = 0.0
if sfx_webbed:
sfx_webbed.play()
# Netted: cannot move? User said "player gets stuck" - so disable movement and main weapon
if player.has_method("_web_net_apply"):
player._web_net_apply(self)
else:
# Fallback: set a flag player script can check
player.set_meta("netted_by_web", self)
# Show net on player (frame 679) - player script will show overlay
if player.has_method("_web_net_show_netted_frame"):
player._web_net_show_netted_frame(true)
# Impact anim 568->573 briefly
anim_timer = 0.0
if sprite:
sprite.frame = impact_frame
# Layer 8 so player attack areas (punch/sword/axe mask 8) can hit and cut the net
collision_layer = 8
collision_mask = 0
func _release_netted_player() -> void:
if hit_player and is_instance_valid(hit_player):
if hit_player.has_method("_web_net_release"):
hit_player._web_net_release(self)
else:
hit_player.remove_meta("netted_by_web")
if hit_player.has_method("_web_net_show_netted_frame"):
hit_player._web_net_show_netted_frame(false)
hit_player = null
# Called when another player attacks this web (to cut the net)
func cut_by_attack(_from_player: Node2D) -> void:
if state == "hit_player":
_release_netted_player()
queue_free()

View File

@@ -0,0 +1 @@
uid://bibyqdhticm5i

View File

@@ -0,0 +1,417 @@
extends "res://scripts/enemy_base.gd"
# Boss Spider Bat: flying boss, activates when player enters ActivationArea.
# Flies around, faces target player (randomize every 10s), tries to stay above players.
# Animations: down, down_right, right, down_left, left (flip down_right/right for left).
# Blinks white on damage. Fires web shots; spawns spiders when previous 3 dead.
var activated: bool = false
var target_switch_timer: float = 0.0
const TARGET_SWITCH_INTERVAL: float = 10.0
var anim_player: AnimationPlayer = null
var spiderbat_sprite: Node2D = null # Spiderbatny
var sfx_web_shot: AudioStreamPlayer2D = null
var sfx_spawn_spider: AudioStreamPlayer2D = null
var boss_room_center: Vector2 = Vector2.ZERO
var fly_speed: float = 60.0
var prefer_above_offset: float = -80.0 # Prefer this many pixels above target (negative Y)
const MIN_DISTANCE_FROM_PLAYER: float = 50.0 # Only back off when very close
# Hold one movement target for several seconds so boss doesn't zigzag
var current_fly_target: Vector2 = Vector2.ZERO
var fly_target_timer: float = 0.0
const FLY_TARGET_DURATION: float = 2.8 # Re-pick target every ~3 seconds
# Web shot attack: only down, down_left, down_right, left, right; fire all 3 at once
var web_shot_scene: PackedScene = null
var attack_state: String = "idle" # "idle" | "charging_web"
var web_attack_timer: float = 0.0
const WEB_CHARGE_TIME: float = 0.9 # Vibrate then fire all 3
const WEB_FIRE_DISTANCE: float = 180.0 # How far each net travels toward its direction
# Directions: down, down_left, down_right, left, right (normalized)
const WEB_DIRECTIONS: Array = [
Vector2(0, 1), # down
Vector2(-0.707, 0.707), # down_left
Vector2(0.707, 0.707), # down_right
Vector2(-1, 0), # left
Vector2(1, 0) # right
]
# Spider spawn
var enemy_spider_scene: PackedScene = null
var spawned_spiders: Array = []
const SPIDER_SPAWN_COUNT: int = 3
const SPIDER_SPAWN_COOLDOWN: float = 18.0 # First spawn after ~18s; same fallback as web for test scenes
var spider_spawn_timer: float = 0.0
# Deterministic RNG from dungeon seed so boss decisions (target, web, fly, spider positions) are identical on host/client if ever needed and for replay consistency
var boss_rng: RandomNumberGenerator = null
func _get_boss_rng() -> RandomNumberGenerator:
if boss_rng != null:
return boss_rng
boss_rng = RandomNumberGenerator.new()
var gw = get_tree().get_first_node_in_group("game_world")
var base_seed = 0
if gw and "dungeon_seed" in gw:
base_seed = gw.dungeon_seed
var idx = get_meta("enemy_index") if has_meta("enemy_index") else 0
boss_rng.seed = base_seed + 90000 + idx # Offset so boss doesn't collide with other RNG use
return boss_rng
func _ready() -> void:
super._ready()
max_health = 1200.0
current_health = 1200.0
# Same collision as normal bats: layer 2 (enemy), mask = players + objects + walls
collision_layer = 2
collision_mask = 1 | 2 | 64 # Players (1), objects (2), walls (7 = bit 6 = 64)
# Boss uses Spiderbatny, not Sprite2D
spiderbat_sprite = get_node_or_null("Spiderbatny")
if spiderbat_sprite == null:
spiderbat_sprite = get_node_or_null("Sprite2D")
anim_player = get_node_or_null("AnimationPlayer")
sfx_web_shot = get_node_or_null("SfxWebShot")
sfx_spawn_spider = get_node_or_null("SfxSpawnSpider")
# Ensure we're in enemy group and have is_boss meta (set by game_world from dungeon data)
if not has_meta("is_boss"):
set_meta("is_boss", true)
# Flying: no gravity, stay above ground (like bats)
position_z = 20.0
# Load web shot and spider scenes
if ResourceLoader.exists("res://scenes/attack_web_shot.tscn"):
web_shot_scene = load("res://scenes/attack_web_shot.tscn") as PackedScene
if ResourceLoader.exists("res://scenes/enemy_spider.tscn"):
enemy_spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
# Start idle (not activated)
velocity = Vector2.ZERO
if anim_player:
anim_player.play("down")
func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_shape_index: int, _local_shape_index: int) -> void:
if not body.is_in_group("player"):
return
if activated:
return
# Only server activates boss
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
return
# Run intro sequence (camera lerp to boss, scream effect, then start) or activate immediately if no game_world
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("start_boss_intro_sequence"):
game_world.start_boss_intro_sequence(self)
return
# No game_world (e.g. test scene): activate immediately
_finish_boss_activation()
func _on_boss_intro_finished() -> void:
# Called by game_world after camera + scream sequence; start the boss fight
_finish_boss_activation()
func _finish_boss_activation() -> void:
activated = true
set_meta("boss_activated", true) # HUD shows bar only when activated
if has_meta("room") and get_meta("room") is Dictionary:
var room = get_meta("room")
var tile_size = 16
boss_room_center = Vector2(
(room.x + room.w / 2.0) * tile_size,
(room.y + room.h / 2.0) * tile_size
)
else:
boss_room_center = global_position
_pick_new_target()
# So joiner sees boss move: sync activated state to clients
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
_sync_boss_activated.rpc()
# Sync initial boss health so joiner's HUD bar shows full bar
var gw = get_tree().get_first_node_in_group("game_world")
var idx = get_meta("enemy_index") if has_meta("enemy_index") else -1
if gw and gw.has_method("_rpc_to_ready_peers") and gw.has_method("_sync_boss_health"):
gw._rpc_to_ready_peers("_sync_boss_health", [name, idx, current_health, max_health])
func _pick_new_target() -> void:
var players = get_tree().get_nodes_in_group("player")
var valid: Array = []
for p in players:
if is_instance_valid(p) and not ("is_dead" in p and p.is_dead):
valid.append(p)
if valid.is_empty():
target_player = null
return
target_player = valid[_get_boss_rng().randi() % valid.size()]
func _physics_process(delta: float) -> void:
if is_dead:
if anim_player and not anim_player.is_playing():
pass
move_and_slide()
return
# Only server runs AI
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
move_and_slide()
return
if not activated:
velocity = Vector2.ZERO
move_and_slide()
return
# Switch target periodically
target_switch_timer += delta
if target_switch_timer >= TARGET_SWITCH_INTERVAL:
target_switch_timer = 0.0
_pick_new_target()
# Spider spawn cooldown (only spawn if previous 3 are defeated)
spider_spawn_timer += delta
_clean_defeated_spiders()
if spider_spawn_timer >= SPIDER_SPAWN_COOLDOWN and spawned_spiders.is_empty() and enemy_spider_scene:
_spawn_spiders()
spider_spawn_timer = 0.0
# Attack state machine
if attack_state == "charging_web":
velocity = Vector2.ZERO
web_attack_timer += delta
if spiderbat_sprite:
var r = _get_boss_rng()
spiderbat_sprite.position = Vector2(r.randf_range(-2, 2), r.randf_range(-2, 2))
if web_attack_timer >= WEB_CHARGE_TIME:
web_attack_timer = 0.0
# Fire all 3 nets at once in 3 directions: down, down_left, down_right (or include left/right)
_Fire_three_nets_at_once()
attack_state = "idle"
if spiderbat_sprite:
spiderbat_sprite.position = Vector2.ZERO
if anim_player:
anim_player.speed_scale = 1.0
move_and_slide()
_sync_boss_position_to_clients()
return
if attack_state == "idle":
web_attack_timer += delta
if web_attack_timer >= 2.5 and web_shot_scene:
web_attack_timer = 0.0
if _get_boss_rng().randf() < 0.5:
attack_state = "charging_web"
web_attack_timer = 0.0
if anim_player:
anim_player.speed_scale = 2.0
# Move: pick a target and hold it for FLY_TARGET_DURATION so we don't zigzag
fly_target_timer += delta
if fly_target_timer >= FLY_TARGET_DURATION or current_fly_target == Vector2.ZERO or global_position.distance_to(current_fly_target) < 20.0:
fly_target_timer = 0.0
if target_player and is_instance_valid(target_player):
var player_pos = target_player.global_position
var to_boss = global_position - player_pos
var dist = to_boss.length()
# Only back off when very close
if dist < MIN_DISTANCE_FROM_PLAYER and dist > 1.0:
current_fly_target = global_position + to_boss.normalized() * 80.0
else:
# Vary target so boss flies in many directions (deterministic from dungeon seed)
var r = _get_boss_rng()
var strategy = r.randi() % 3
if strategy == 0:
var side = 1.0 if r.randf() > 0.5 else -1.0
current_fly_target = player_pos + Vector2(side * r.randf_range(50.0, 130.0), r.randf_range(prefer_above_offset - 50.0, prefer_above_offset + 30.0))
elif strategy == 1:
current_fly_target = boss_room_center + Vector2(r.randf_range(-70.0, 70.0), r.randf_range(-90.0, -10.0))
else:
current_fly_target = player_pos + Vector2(r.randf_range(-110.0, 110.0), r.randf_range(-130.0, -35.0))
else:
var r = _get_boss_rng()
current_fly_target = boss_room_center + Vector2(r.randf_range(-80, 80), r.randf_range(-80, -20))
var dir = (current_fly_target - global_position).normalized()
velocity = dir * fly_speed
_update_facing(dir)
move_and_slide()
_sync_boss_position_to_clients()
func _sync_boss_position_to_clients() -> void:
# Boss overrides _physics_process and doesn't call super, so we must sync position ourselves (like enemy_base)
if not multiplayer.has_multiplayer_peer() or not is_multiplayer_authority() or not is_inside_tree():
return
var gw = get_tree().get_first_node_in_group("game_world")
if not gw or not gw.has_method("_rpc_to_ready_peers") or not gw.has_method("_sync_enemy_position"):
return
var idx = get_meta("enemy_index") if has_meta("enemy_index") else -1
gw._rpc_to_ready_peers("_sync_enemy_position", [name, idx, position, velocity, position_z, current_direction, 0, "", 0, -1])
@rpc("any_peer", "reliable")
func _sync_boss_activated() -> void:
# Clients: apply same activation state so boss moves and shows bar
if is_multiplayer_authority():
return
activated = true
set_meta("boss_activated", true)
if has_meta("room") and get_meta("room") is Dictionary:
var room = get_meta("room")
var tile_size = 16
boss_room_center = Vector2(
(room.x + room.w / 2.0) * tile_size,
(room.y + room.h / 2.0) * tile_size
)
else:
boss_room_center = global_position
_pick_new_target()
func _update_facing(dir: Vector2) -> void:
if not anim_player or not spiderbat_sprite:
return
# Prefer staying above: if moving up (negative Y), use down/down_left/down_right
var anim_name := "down"
var flip := 1
if abs(dir.x) > 0.2:
if dir.x > 0:
anim_name = "down_right" if dir.y > -0.3 else "right"
flip = 1
else:
anim_name = "down_left" if dir.y > -0.3 else "left"
flip = -1
else:
anim_name = "down"
flip = 1
if spiderbat_sprite.scale.x != flip:
spiderbat_sprite.scale.x = flip
if anim_player.current_animation != anim_name:
anim_player.play(anim_name)
anim_player.speed_scale = 1.0
func _Fire_three_nets_at_once() -> void:
if not web_shot_scene or not is_inside_tree():
return
if sfx_web_shot:
sfx_web_shot.play()
var parent_node: Node = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
parent_node = game_world.get_node_or_null("Entities")
if not parent_node:
parent_node = get_parent()
if not parent_node:
parent_node = get_tree().current_scene
if not parent_node:
return
# Pick 3 directions from down, down_left, down_right, left, right (no duplicates, deterministic)
var indices = [0, 1, 2, 3, 4]
var r = _get_boss_rng()
for i in range(indices.size() - 1, 0, -1):
var j = r.randi() % (i + 1)
var t = indices[i]
indices[i] = indices[j]
indices[j] = t
var target_positions: Array[Vector2] = []
for i in range(3):
var dir: Vector2 = WEB_DIRECTIONS[indices[i]]
var target_pos = global_position + dir * WEB_FIRE_DISTANCE
target_positions.append(target_pos)
var shot = web_shot_scene.instantiate()
shot.global_position = global_position
if shot.has_method("set_target"):
shot.set_target(target_pos)
if shot.has_method("set_fired_by_boss"):
shot.set_fired_by_boss(self)
parent_node.add_child(shot)
# Sync web shots to clients so they see the nets
var gw = get_tree().get_first_node_in_group("game_world")
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and gw and gw.has_method("_rpc_to_ready_peers") and target_positions.size() >= 3:
var bp = global_position
gw._rpc_to_ready_peers("_sync_boss_web_shot", [bp.x, bp.y, target_positions[0].x, target_positions[0].y, target_positions[1].x, target_positions[1].y, target_positions[2].x, target_positions[2].y])
func _clean_defeated_spiders() -> void:
for i in range(spawned_spiders.size() - 1, -1, -1):
if not is_instance_valid(spawned_spiders[i]) or ("is_dead" in spawned_spiders[i] and spawned_spiders[i].is_dead):
spawned_spiders.remove_at(i)
# Pick SPIDER_SPAWN_COUNT positions inside the boss room, near the boss (so spiders don't spawn in walls or outside).
func _get_spider_spawn_positions_in_room() -> Array:
const TILE_SIZE: int = 16
var r = _get_boss_rng()
var result: Array = []
# Reference position: prefer current boss position, but clamp to room; if no room, use boss_room_center
var ref_pos = global_position
if has_meta("room") and get_meta("room") is Dictionary:
var room: Dictionary = get_meta("room")
# Room interior (floor): tiles from (room.x+2, room.y+2) to (room.x+room.w-3, room.y+room.h-3) inclusive
var min_wx: float = (room.x + 2) * TILE_SIZE + 8.0
var max_wx: float = (room.x + room.w - 2) * TILE_SIZE - 8.0
var min_wy: float = (room.y + 2) * TILE_SIZE + 8.0
var max_wy: float = (room.y + room.h - 2) * TILE_SIZE - 8.0
if min_wx >= max_wx or min_wy >= max_wy:
# Fallback: single center tile
min_wx = (room.x + room.w / 2) * TILE_SIZE - 8.0
max_wx = min_wx + 16.0
min_wy = (room.y + room.h / 2) * TILE_SIZE - 8.0
max_wy = min_wy + 16.0
# Keep ref_pos inside room so "near boss" is still in room when boss is flying near walls
ref_pos.x = clampf(ref_pos.x, min_wx, max_wx)
ref_pos.y = clampf(ref_pos.y, min_wy, max_wy)
for i in range(SPIDER_SPAWN_COUNT):
# Random offset near ref (boss), then clamp to room interior
var pos = ref_pos + Vector2(r.randf_range(-55.0, 55.0), r.randf_range(-55.0, 55.0))
pos.x = clampf(pos.x, min_wx, max_wx)
pos.y = clampf(pos.y, min_wy, max_wy)
result.append(pos)
else:
# No room meta (e.g. test scene): spawn near boss
for i in range(SPIDER_SPAWN_COUNT):
result.append(global_position + Vector2(r.randf_range(-40.0, 40.0), r.randf_range(-20.0, 20.0)))
return result
func _spawn_spiders() -> void:
if not enemy_spider_scene or not is_inside_tree():
return
var game_world = get_tree().get_first_node_in_group("game_world")
var positions: Array = _get_spider_spawn_positions_in_room()
if positions.is_empty():
return
if sfx_spawn_spider:
sfx_spawn_spider.play()
# Server: spawn via game_world so clients get RPC and see the spiders
if game_world and game_world.has_method("request_spawn_boss_spiders"):
spawned_spiders = game_world.request_spawn_boss_spiders(positions)
return
# Fallback (no game_world or single-player): spawn locally
var parent_node: Node = game_world.get_node_or_null("Entities") if game_world else null
if not parent_node:
parent_node = get_parent()
if not parent_node:
parent_node = get_tree().current_scene
if not parent_node:
return
for i in range(SPIDER_SPAWN_COUNT):
var spider = enemy_spider_scene.instantiate()
spider.global_position = positions[i]
parent_node.add_child(spider)
spawned_spiders.append(spider)
func _flash_damage() -> void:
# Boss blinks white (override base red flash)
var node_to_flash = spiderbat_sprite if spiderbat_sprite else sprite
if node_to_flash:
var tween = create_tween()
tween.tween_property(node_to_flash, "modulate", Color(2, 2, 2), 0.1)
tween.tween_property(node_to_flash, "modulate", Color(1, 1, 1), 0.1)
func _play_death_animation() -> void:
# Play boss death animation then free
if anim_player and anim_player.has_animation("die"):
attack_state = "idle"
velocity = Vector2.ZERO
anim_player.play("die")
await anim_player.animation_finished
await get_tree().create_timer(0.15).timeout
if is_instance_valid(self):
queue_free()

View File

@@ -0,0 +1 @@
uid://bgdwn43m2yrtl

View File

@@ -21,17 +21,17 @@ var race: String = "Human" # "Dwarf", "Elf", or "Human"
@export var mp: float = 20.0 @export var mp: float = 20.0
# default skin is human1 # default skin is human1
var skin:String = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png" var skin: String = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png"
# default no values for these: # default no values for these:
var facial_hair:String = "" var facial_hair: String = ""
var facial_hair_color:Color = Color.WHITE var facial_hair_color: Color = Color.WHITE
var hairstyle:String = "" var hairstyle: String = ""
var hair_color:Color = Color.WHITE var hair_color: Color = Color.WHITE
var eyes:String = "" var eyes: String = ""
var eye_color:Color = Color.WHITE var eye_color: Color = Color.WHITE
var eye_lashes:String = "" var eye_lashes: String = ""
var eyelash_color:Color = Color.WHITE var eyelash_color: Color = Color.WHITE
var add_on:String = "" var add_on: String = ""
var bonusmaxhp: float = 0.0 var bonusmaxhp: float = 0.0
var bonusmaxmp: float = 0.0 var bonusmaxmp: float = 0.0
@@ -47,7 +47,7 @@ var pending_stat_points: int = 0
const LEVEL_UP_STAT_NAMES: Array = ["str", "dex", "int", "end", "wis", "lck", "per"] const LEVEL_UP_STAT_NAMES: Array = ["str", "dex", "int", "end", "wis", "lck", "per"]
#calculated values #calculated values
var stats:Array = [ var stats: Array = [
5, 5,
4, 4,
5, 5,
@@ -62,7 +62,7 @@ var stats:Array = [
var inventory: Array = [] var inventory: Array = []
# mainhand, offhand, headgear, body, feet, accessory (6 total) # mainhand, offhand, headgear, body, feet, accessory (6 total)
var equipment:Dictionary = { var equipment: Dictionary = {
"mainhand": null, "mainhand": null,
"offhand": null, "offhand": null,
"headgear": null, "headgear": null,
@@ -137,7 +137,7 @@ func getCalculatedStats():
pass pass
func get_pass(iStr:String): func get_pass(iStr: String):
var cnt = 0 var cnt = 0
if equipment["mainhand"] != null: if equipment["mainhand"] != null:
for key in equipment["mainhand"].modifiers.keys(): for key in equipment["mainhand"].modifiers.keys():
@@ -217,11 +217,17 @@ var crit_chance: float:
get: get:
return (baseStats.lck + get_pass("lck")) * 1.2 return (baseStats.lck + get_pass("lck")) * 1.2
# Temporary dodge buff from potions (added to base dodge chance while active)
var buff_dodge_chance: float = 0.0
var buff_dodge_chance_remaining: float = 0.0
var dodge_chance: float: var dodge_chance: float:
get: get:
# Dodge chance based on DEX (very low % per point, as per user request) # Dodge chance based on DEX (very low % per point, as per user request)
# Each point of DEX gives 0.5% dodge chance (so 20 DEX = 10% dodge) # Each point of DEX gives 0.5% dodge chance (so 20 DEX = 10% dodge)
return (baseStats.dex + get_pass("dex")) * 0.005 # Plus active potion buff (e.g. dodge potion +15%)
var base_dodge = (baseStats.dex + get_pass("dex")) * 0.005
return clamp(base_dodge + buff_dodge_chance, 0.0, 1.0)
var hit_chance: float: var hit_chance: float:
get: get:
@@ -230,6 +236,21 @@ var hit_chance: float:
# Formula: 95% base + (DEX * 0.3%) # Formula: 95% base + (DEX * 0.3%)
return 0.95 + ((baseStats.dex + get_pass("dex")) * 0.003) return 0.95 + ((baseStats.dex + get_pass("dex")) * 0.003)
func add_buff_dodge_chance(amount: float, duration_sec: float) -> void:
"""Apply temporary dodge chance buff (e.g. from dodge potion). Replaces existing dodge buff."""
buff_dodge_chance = amount
buff_dodge_chance_remaining = duration_sec
character_changed.emit(self)
func tick_buffs(delta: float) -> void:
"""Decrease remaining time on temporary buffs; clear when expired."""
if buff_dodge_chance_remaining > 0.0:
buff_dodge_chance_remaining -= delta
if buff_dodge_chance_remaining <= 0.0:
buff_dodge_chance_remaining = 0.0
buff_dodge_chance = 0.0
character_changed.emit(self)
var xp_to_next_level: float: var xp_to_next_level: float:
get: get:
# Scale EXP requirements more aggressively - gets harder to level as you go # Scale EXP requirements more aggressively - gets harder to level as you go
@@ -292,12 +313,14 @@ func modify_health(amount: float, allow_overheal: bool = false) -> void:
if hp <= 0.001: if hp <= 0.001:
hp = 0.0 hp = 0.0
health_changed.emit(hp, maxhp) health_changed.emit(hp, maxhp)
character_changed.emit(self) # Do not emit character_changed here - inventory uses health_changed for HP bar only.
# Emitting character_changed would trigger full UI refresh on every heal/regen tick and cause blinking.
func modify_mana(amount: float) -> void: func modify_mana(amount: float) -> void:
mp = clamp(mp + amount, 0, maxmp) mp = clamp(mp + amount, 0, maxmp)
mana_changed.emit(mp, maxmp) mana_changed.emit(mp, maxmp)
character_changed.emit(self) # Do not emit character_changed here - inventory uses mana_changed for MP bar only.
# Emitting character_changed would trigger full UI refresh every regen tick and cause blinking.
func calculate_damage(base_damage: float, is_magical: bool = false, is_critical: bool = false) -> float: func calculate_damage(base_damage: float, is_magical: bool = false, is_critical: bool = false) -> float:
# Apply defense reduction - more like D&D where defense provides minimal protection # Apply defense reduction - more like D&D where defense provides minimal protection
@@ -346,7 +369,7 @@ func restore_mana(amount: float) -> void:
func saveInventory() -> Array: func saveInventory() -> Array:
var inventorySave = [] var inventorySave = []
for it:Item in inventory: for it: Item in inventory:
inventorySave.push_back(it.save()) inventorySave.push_back(it.save())
return inventorySave return inventorySave
@@ -365,7 +388,7 @@ func loadInventory(iArr: Array):
inventory.clear() # remove previous content inventory.clear() # remove previous content
for iDic in iArr: for iDic in iArr:
if iDic != null: if iDic != null:
inventory.push_back( Item.new(iDic) ) inventory.push_back(Item.new(iDic))
pass pass
func loadEquipment(iDic: Dictionary): func loadEquipment(iDic: Dictionary):
@@ -504,29 +527,29 @@ func calculateStats():
pass' pass'
func add_coin(iAmount:int): func add_coin(iAmount: int):
coin += iAmount coin += iAmount
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func drop_item(iItem:Item): func drop_item(iItem: Item):
var index = 0 var index = 0
for item in inventory: for item in inventory:
if item == iItem: if item == iItem:
break break
index+=1 index += 1
inventory.remove_at(index) inventory.remove_at(index)
emit_signal("signal_drop_item", iItem) emit_signal("signal_drop_item", iItem)
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func drop_equipment(iItem:Item): func drop_equipment(iItem: Item):
unequip_item(iItem, false) unequip_item(iItem, false)
# directly remove the item from the inventory # directly remove the item from the inventory
drop_item(iItem) drop_item(iItem)
pass pass
func add_item(iItem:Item): func add_item(iItem: Item):
# Try to stack with existing items if possible # Try to stack with existing items if possible
if iItem.can_have_multiple_of: if iItem.can_have_multiple_of:
for existing_item in inventory: for existing_item in inventory:
@@ -570,7 +593,7 @@ func add_item(iItem:Item):
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func unequip_item(iItem:Item, updateChar:bool = true): func unequip_item(iItem: Item, updateChar: bool = true):
if iItem.equipment_type == Item.EquipmentType.NONE: if iItem.equipment_type == Item.EquipmentType.NONE:
return return
@@ -612,7 +635,7 @@ func forceUpdate():
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func equip_item(iItem:Item, insert_index: int = -1): func equip_item(iItem: Item, insert_index: int = -1):
# insert_index: if >= 0, place old item at this index instead of at the end # insert_index: if >= 0, place old item at this index instead of at the end
if iItem.equipment_type == Item.EquipmentType.NONE: if iItem.equipment_type == Item.EquipmentType.NONE:
return return
@@ -692,14 +715,14 @@ func equip_item(iItem:Item, insert_index: int = -1):
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func setSkin(iValue:int): func setSkin(iValue: int):
if iValue < 0 or iValue > 6: if iValue < 0 or iValue > 6:
return return
skin = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(iValue+1) + ".png" skin = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(iValue + 1) + ".png"
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func setFacialHair(iType:int): func setFacialHair(iType: int):
if iType < 0 or iType > 3: if iType < 0 or iType > 3:
return return
@@ -722,7 +745,7 @@ func setFacialHair(iType:int):
pass pass
func setHair(iType:int): func setHair(iType: int):
if iType < 0 or iType > 12: if iType < 0 or iType > 12:
return return
if iType == 0: if iType == 0:
@@ -731,7 +754,7 @@ func setHair(iType:int):
return return
hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/FHairstyle" + str(iType) hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/FHairstyle" + str(iType)
if iType >= 5: # male hairstyles if iType >= 5: # male hairstyles
hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(iType-4) hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(iType - 4)
hairstyle += "White.png" hairstyle += "White.png"
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
@@ -837,7 +860,7 @@ func setFacialHairColor(iColor: Color):
facial_hair_color = iColor facial_hair_color = iColor
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass
func setHairColor(iColor:Color): func setHairColor(iColor: Color):
hair_color = iColor hair_color = iColor
emit_signal("character_changed", self) emit_signal("character_changed", self)
pass pass

View File

@@ -0,0 +1,68 @@
extends Node2D
# Visual effect spawned where a player detects something (trap, cracked ground, enemy, hidden chest).
# Effect type sets frame range and PointLight2D color (optional light_energy overrides default):
# "chest" -> frames 169-179, blue light (hidden chest)
# "trap" -> frames 274-284, purple light (trap or cracked ground)
# "enemy" -> frames 2123-2135, red light (e.g. enemy_hand)
const FRAME_RATE: float = 8.0 # frames per second
const LIFETIME: float = 30.0
const FADE_DURATION: float = 2.0
# Effect type -> { frames: Array, light_color: Color, optional light_energy: float }
const EFFECT_CONFIG: Dictionary = {
"chest": {"frames": [169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179], "light_color": Color(0.35, 0.5, 0.95, 1)},
"trap": {"frames": [274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284], "light_color": Color(0.7, 0.35, 0.95, 1)},
"enemy": {"frames": [2123, 2124, 2125, 2126, 2127, 2128, 2129, 2130, 2131, 2132, 2133, 2134, 2135], "light_color": Color(1.0, 0.1, 0.1, 1), "light_energy": 1.4}
}
var _frames: Array = []
var _elapsed: float = 0.0
var _fading: bool = false
var _fade_elapsed: float = 0.0
var _initial_light_energy: float = 0.9
@onready var fx_sprite: Sprite2D = $FxSprite
@onready var detect_light: PointLight2D = $DetectLight
func _ready() -> void:
pass
func setup(world_pos: Vector2, effect_type: String = "chest") -> void:
global_position = world_pos
if EFFECT_CONFIG.has(effect_type):
var cfg = EFFECT_CONFIG[effect_type]
_frames = cfg.frames
if detect_light:
detect_light.color = cfg.light_color
if cfg.get("light_energy", 0.0) > 0.0:
detect_light.energy = cfg.light_energy
else:
_frames = EFFECT_CONFIG.chest.frames
if fx_sprite and _frames.size() > 0:
fx_sprite.frame = _frames[0]
if detect_light:
_initial_light_energy = detect_light.energy
detect_light.enabled = true
func _process(delta: float) -> void:
_elapsed += delta
if _fading:
_fade_elapsed += delta
var t = clampf(_fade_elapsed / FADE_DURATION, 0.0, 1.0)
var a = 1.0 - t
if fx_sprite:
fx_sprite.modulate.a = a
if detect_light:
detect_light.energy = _initial_light_energy * (1.0 - t)
if _fade_elapsed >= FADE_DURATION:
queue_free()
return
# Loop frames for this effect type
if fx_sprite and _frames.size() > 0:
var frame_idx = int(_elapsed * FRAME_RATE) % _frames.size()
fx_sprite.frame = _frames[frame_idx]
if _elapsed >= LIFETIME:
_fading = true
_fade_elapsed = 0.0

View File

@@ -0,0 +1 @@
uid://b45h84vbq3jw

View File

@@ -7,10 +7,10 @@ extends StaticBody2D
#StoneDoor and GateDoor CAN be opened in start, and become closed when entering it's room #StoneDoor and GateDoor CAN be opened in start, and become closed when entering it's room
#Then you must press a switch in the room or maybe you need to defeat all enemies in the room #Then you must press a switch in the room or maybe you need to defeat all enemies in the room
@export var is_closed: bool = true @export var is_closed: bool = true
var is_closing:bool = false var is_closing: bool = false
var is_opening:bool = false var is_opening: bool = false
var time_to_move:float = 0.2 var time_to_move: float = 0.2
var move_timer:float = 0.0 var move_timer: float = 0.0
var animation_start_position: Vector2 = Vector2.ZERO # Position when animation started var animation_start_position: Vector2 = Vector2.ZERO # Position when animation started
var closed_position: Vector2 = Vector2.ZERO # Position when door is closed (local) var closed_position: Vector2 = Vector2.ZERO # Position when door is closed (local)
@@ -45,9 +45,9 @@ func _ready() -> void:
# Rotate door first based on direction (original order) # Rotate door first based on direction (original order)
if direction == "Left": if direction == "Left":
self.rotate(-PI/2) self.rotate(-PI / 2)
elif direction == "Right": elif direction == "Right":
self.rotate(PI/2) self.rotate(PI / 2)
elif direction == "Down": elif direction == "Down":
self.rotate(PI) self.rotate(PI)
@@ -530,7 +530,7 @@ func _ready_after_setup():
# Update open_offset for animation logic (offset from closed to open) # Update open_offset for animation logic (offset from closed to open)
# This is used when opening from closed position # This is used when opening from closed position
open_offset = -closed_offset # open_offset = (0, -16) means open is 16px up from closed open_offset = - closed_offset # open_offset = (0, -16) means open is 16px up from closed
LogManager.log("Door: Calculated positions - open: " + str(open_position) + ", closed: " + str(closed_position) + ", closed_offset: " + str(closed_offset) + ", open_offset: " + str(open_offset), LogManager.CATEGORY_DOOR) LogManager.log("Door: Calculated positions - open: " + str(open_position) + ", closed: " + str(closed_position) + ", closed_offset: " + str(closed_offset) + ", open_offset: " + str(open_offset), LogManager.CATEGORY_DOOR)
@@ -804,7 +804,35 @@ func _check_puzzle_state():
switches_activated = false switches_activated = false
puzzle_solved = false puzzle_solved = false
func _are_all_enemies_defeated_boss_room() -> bool:
# Boss room: check ALL enemies in blocking_room by position (pre-placed boss counts)
var target_room = blocking_room if blocking_room and not blocking_room.is_empty() else {}
if target_room.is_empty():
return false
var tile_size = 16
var room_min_x = (target_room.x + 2) * tile_size
var room_max_x = (target_room.x + target_room.w - 2) * tile_size
var room_min_y = (target_room.y + 2) * tile_size
var room_max_y = (target_room.y + target_room.h - 2) * tile_size
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
if not entities_node:
return false
for child in entities_node.get_children():
if not child.is_in_group("enemy"):
continue
var pos = child.global_position
if pos.x < room_min_x or pos.x >= room_max_x or pos.y < room_min_y or pos.y >= room_max_y:
continue
var is_dead = child.is_dead if "is_dead" in child else (child.is_queued_for_deletion() or not child.is_inside_tree())
if not is_dead:
return false
return true
func _are_all_enemies_defeated() -> bool: func _are_all_enemies_defeated() -> bool:
# Boss room door: check ALL enemies in room (pre-placed boss), not just spawner-spawned
if has_meta("boss_room_door") and get_meta("boss_room_door"):
return _are_all_enemies_defeated_boss_room()
# Check if all enemies spawned from spawners in the puzzle room are defeated # Check if all enemies spawned from spawners in the puzzle room are defeated
# CRITICAL: Only check enemies that were SPAWNED from spawners (not pre-spawned enemies) # CRITICAL: Only check enemies that were SPAWNED from spawners (not pre-spawned enemies)
# Use room1 (the room this door is IN) or blocking_room for checking enemies # Use room1 (the room this door is IN) or blocking_room for checking enemies
@@ -932,7 +960,6 @@ func _are_all_enemies_defeated() -> bool:
# No spawned enemies found - check if spawners have actually spawned enemies before # No spawned enemies found - check if spawners have actually spawned enemies before
# CRITICAL: Only consider puzzle solved if spawners have spawned enemies and they're all dead # CRITICAL: Only consider puzzle solved if spawners have spawned enemies and they're all dead
# Don't solve if spawners haven't spawned yet (e.g., spawn_on_ready=false and player hasn't entered room) # Don't solve if spawners haven't spawned yet (e.g., spawn_on_ready=false and player hasn't entered room)
var spawners_in_room = [] var spawners_in_room = []
var spawners_that_have_spawned = [] var spawners_that_have_spawned = []
@@ -1284,6 +1311,8 @@ func _sync_door_open():
var is_actually_open = distance_to_closed > 5.0 var is_actually_open = distance_to_closed > 5.0
if not is_actually_open and not is_opening: if not is_actually_open and not is_opening:
# Disable teleporter (stops particle emitter) when door opens
$TeleporterIntoClosedRoom.is_enabled = false
# Door is closed - open it # Door is closed - open it
if closed_position != Vector2.ZERO: if closed_position != Vector2.ZERO:
position = closed_position position = closed_position

View File

@@ -101,7 +101,10 @@ const FALLOUT_CORNER_INNER_DOWN_RIGHT = Vector2i(16, 13) # 302
# But we want at least 3x3 floor, so room size should be at least 7x7 total # But we want at least 3x3 floor, so room size should be at least 7x7 total
const MIN_ROOM_SIZE = 7 # Minimum room size in tiles (includes walls, so 3x3 floor minimum) const MIN_ROOM_SIZE = 7 # Minimum room size in tiles (includes walls, so 3x3 floor minimum)
const MAX_ROOM_SIZE = 12 # Maximum room size in tiles const MAX_ROOM_SIZE = 12 # Maximum room size in tiles
const MIN_HOLE_SIZE = 9 # Minimum hole size in rooms (9x9 tiles) const LEVEL4_BOSS_ROOM_MIN_SIZE = 24 # Level 4: boss arena minimum (spider bat needs space)
const LEVEL4_BOSS_ROOM_MAX_SIZE = 26 # Level 4: much wider boss room (e.g. 26 tiles)
const LEVEL4_GEN_MAP_SIZE = 256 # Level 4: smaller canvas for faster gen; still enough for boss+exit+rooms; crop to content after
const LEVEL4_CROP_MARGIN = 4 # Level 4: margin when cropping dungeon to content
const DOOR_MIN_WIDTH = 3 # Minimum width for door frames const DOOR_MIN_WIDTH = 3 # Minimum width for door frames
const DOOR_MAX_WIDTH = 5 # Maximum width for door frames const DOOR_MAX_WIDTH = 5 # Maximum width for door frames
const CORRIDOR_WIDTH = 1 # Corridor width in tiles (1 tile) const CORRIDOR_WIDTH = 1 # Corridor width in tiles (1 tile)
@@ -114,6 +117,11 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
else: else:
rng.randomize() rng.randomize()
# Level 4 (boss level): generate on a LARGE canvas; crop to content at the end
if level == 4:
map_size = Vector2i(LEVEL4_GEN_MAP_SIZE, LEVEL4_GEN_MAP_SIZE)
LogManager.log("DungeonGenerator: Level 4 - Using large canvas " + str(map_size.x) + "x" + str(map_size.y) + "; will crop to content after", LogManager.CATEGORY_DUNGEON)
# Calculate target room count based on level # Calculate target room count based on level
# Level 1: 7-8 rooms, then increase by 2-3 rooms per level # Level 1: 7-8 rooms, then increase by 2-3 rooms per level
var target_room_count = 7 + (level - 1) * 2 + rng.randi_range(0, 1) # Level 1: 7-8, Level 2: 9-10, etc. var target_room_count = 7 + (level - 1) * 2 + rng.randi_range(0, 1) # Level 1: 7-8, Level 2: 9-10, etc.
@@ -137,10 +145,62 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
var all_rooms = [] var all_rooms = []
var all_doors = [] var all_doors = []
var max_room_size = MAX_ROOM_SIZE
var level4_boss_room_size = LEVEL4_BOSS_ROOM_MAX_SIZE
# 1. Create first room at a random position if level == 4:
var first_w = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE) # --- LEVEL 4: BOSS first, then EXIT (only 1 door: boss<->exit), then MANY other rooms (only connected to boss). SPAWN = one of those other rooms. ---
var first_h = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE) # 1. Create BOSS room first at center of the large canvas
var boss_w = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
var boss_h = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
var boss_room = {
"x": int((map_size.x - boss_w) / 2),
"y": int((map_size.y - boss_h) / 2),
"w": boss_w,
"h": boss_h,
"modifiers": []
}
_set_floor(boss_room, grid, tile_grid, map_size, rng)
all_rooms.append(boss_room)
LogManager.log("DungeonGenerator: Level 4 - Created BOSS room first at center (" + str(boss_room.x) + "," + str(boss_room.y) + ") size " + str(boss_room.w) + "x" + str(boss_room.h), LogManager.CATEGORY_DUNGEON)
# 2. Create EXIT room adjacent to BOSS. ONLY this room connects to exit. ONE door: boss <-> exit.
var exit_room = _try_place_room_near(boss_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
var exit_attempts = 50
while exit_room.w == 0 and exit_attempts > 0:
exit_room = _try_place_room_near(boss_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
exit_attempts -= 1
if exit_room.w > 0:
_set_floor(exit_room, grid, tile_grid, map_size, rng)
all_rooms.append(exit_room)
var boss_exit_door = _create_corridor_between_rooms(boss_room, exit_room, grid, tile_grid, map_size, all_rooms, rng)
if boss_exit_door.size() > 0:
all_doors.append(boss_exit_door)
LogManager.log("DungeonGenerator: Level 4 - EXIT room attached to BOSS (only door to exit)", LogManager.CATEGORY_DUNGEON)
else:
LogManager.log_error("DungeonGenerator: Level 4 - Failed to create boss<->exit corridor", LogManager.CATEGORY_DUNGEON)
else:
LogManager.log_error("DungeonGenerator: Level 4 - Could not place exit room adjacent to boss", LogManager.CATEGORY_DUNGEON)
# 3. Place MANY other rooms near the BOSS or near any existing "other" room (never near exit). So only the boss connects to exit; others attach to boss or to each other for more rooms.
var min_other_rooms = 10 # At least 10 other rooms for spawn candidates and path to boss
var target_other = maxi(min_other_rooms, target_room_count - 2) # boss + exit already; rest are "other"
var attempts = 6000
while attempts > 0 and (all_rooms.size() < 2 + target_other):
# Pick source: boss (index 0) or any existing "other" room (index 2+) so we build a cluster and get more rooms
var source_room = boss_room
if all_rooms.size() > 2 and rng.randf() < 0.6:
source_room = all_rooms[rng.randi_range(2, all_rooms.size() - 1)]
var new_room = _try_place_room_near(source_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
if new_room.w > 0:
_set_floor(new_room, grid, tile_grid, map_size, rng)
all_rooms.append(new_room)
attempts -= 1
LogManager.log("DungeonGenerator: Level 4 - Generated " + str(all_rooms.size()) + " rooms (1 boss + 1 exit + " + str(all_rooms.size() - 2) + " others for spawn/path)", LogManager.CATEGORY_DUNGEON)
else:
# --- Non-boss levels: first room random, then place others near any room ---
var first_w = rng.randi_range(MIN_ROOM_SIZE, max_room_size)
var first_h = rng.randi_range(MIN_ROOM_SIZE, max_room_size)
var first_room = { var first_room = {
"x": rng.randi_range(4, map_size.x - first_w - 4), "x": rng.randi_range(4, map_size.x - first_w - 4),
"y": rng.randi_range(4, map_size.y - first_h - 4), "y": rng.randi_range(4, map_size.y - first_h - 4),
@@ -148,60 +208,58 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
"h": first_h, "h": first_h,
"modifiers": [] "modifiers": []
} }
_set_floor(first_room, grid, tile_grid, map_size, rng) _set_floor(first_room, grid, tile_grid, map_size, rng)
all_rooms.append(first_room) all_rooms.append(first_room)
var max_for_extra_rooms = max_room_size
# 2. Try to place rooms until we reach target count or can't fit any more
var attempts = 1000 var attempts = 1000
while attempts > 0 and all_rooms.size() < target_room_count and all_rooms.size() > 0: while attempts > 0 and all_rooms.size() < target_room_count and all_rooms.size() > 0:
var source_room = all_rooms[rng.randi() % all_rooms.size()] var source_room = all_rooms[rng.randi() % all_rooms.size()]
var new_room = _try_place_room_near(source_room, grid, map_size, rng) var new_room = _try_place_room_near(source_room, grid, map_size, rng, max_for_extra_rooms, level)
if new_room.w > 0: if new_room.w > 0:
_set_floor(new_room, grid, tile_grid, map_size, rng) _set_floor(new_room, grid, tile_grid, map_size, rng)
all_rooms.append(new_room) all_rooms.append(new_room)
attempts -= 1 attempts -= 1
LogManager.log("DungeonGenerator: Generated " + str(all_rooms.size()) + " rooms (target was " + str(target_room_count) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Generated " + str(all_rooms.size()) + " rooms (target was " + str(target_room_count) + ")", LogManager.CATEGORY_DUNGEON)
# 3. Connect rooms with corridors/doors # 3. Connect rooms with corridors/doors (level 4 already has boss<->exit; this connects the rest)
if all_rooms.size() > 1: if all_rooms.size() > 1:
_connect_rooms(all_rooms, grid, tile_grid, map_size, all_doors, rng) _connect_rooms(all_rooms, grid, tile_grid, map_size, all_doors, rng)
# 4. Add random holes in some rooms (minimum 9x9 tiles) # 4. (No holes - we only use sprinkled single fallout tiles and cracked tiles)
for room in all_rooms:
if rng.randf() < 0.3: # 30% chance for a hole
_add_hole_to_room(room, grid, tile_grid, map_size, rng)
# 5. Mark start room (random room for variety) # 5 & 6. Start/exit and reachability
var start_room_index = rng.randi() % all_rooms.size() # Level 4: GUARANTEE boss room (index 0), spawn → boss → exit. We use the BOSS ROOM as the connectivity root:
# only keep rooms reachable FROM the boss, so the boss always has doors and the path is spawn → boss → exit.
# Other levels: pick random start, then keep rooms reachable from start.
var start_room_index: int
var exit_room_index = -1 # Declare exit_room_index early to avoid scope issues var exit_room_index = -1 # Declare exit_room_index early to avoid scope issues
var reachable_rooms: Array
if level == 4:
# Level 4: reachability from BOSS room (index 0) so we never strip doors from the boss
reachable_rooms = _find_reachable_rooms(all_rooms[0], all_rooms, all_doors)
LogManager.log("DungeonGenerator: Level 4 - Found " + str(reachable_rooms.size()) + " rooms reachable from BOSS room (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON)
else:
start_room_index = rng.randi() % all_rooms.size()
all_rooms[start_room_index].modifiers.append({"type": "START"}) all_rooms[start_room_index].modifiers.append({"type": "START"})
reachable_rooms = _find_reachable_rooms(all_rooms[start_room_index], all_rooms, all_doors)
# 6. Mark exit room (farthest REACHABLE room from start)
# First find all reachable rooms from start
var reachable_rooms = _find_reachable_rooms(all_rooms[start_room_index], all_rooms, all_doors)
LogManager.log("DungeonGenerator: Found " + str(reachable_rooms.size()) + " reachable rooms from start (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Found " + str(reachable_rooms.size()) + " reachable rooms from start (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON)
# CRITICAL: Remove inaccessible rooms (rooms not reachable from start) # CRITICAL: Remove inaccessible rooms (rooms not in reachable_rooms)
# Store the start room before filtering (it should always be reachable) var start_room_ref: Dictionary
var start_room_ref = all_rooms[start_room_index] if level != 4:
start_room_ref = all_rooms[start_room_index]
var inaccessible_count = 0 var inaccessible_count = 0
# Create new array with only reachable rooms # Create new array with only reachable rooms (value-based comparison)
# Use value-based comparison (x, y, w, h) to check if room is reachable
var filtered_rooms = [] var filtered_rooms = []
for room in all_rooms: for idx in range(all_rooms.size()):
var room = all_rooms[idx]
var is_reachable = false var is_reachable = false
# Check if this room is in the reachable_rooms list by comparing values
for reachable_room in reachable_rooms: for reachable_room in reachable_rooms:
if reachable_room.x == room.x and reachable_room.y == room.y and \ if reachable_room.x == room.x and reachable_room.y == room.y and \
reachable_room.w == room.w and reachable_room.h == room.h: reachable_room.w == room.w and reachable_room.h == room.h:
is_reachable = true is_reachable = true
break break
if is_reachable: if is_reachable:
filtered_rooms.append(room) filtered_rooms.append(room)
else: else:
@@ -214,7 +272,9 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
if inaccessible_count > 0: if inaccessible_count > 0:
LogManager.log("DungeonGenerator: Removed " + str(inaccessible_count) + " inaccessible room(s). Remaining rooms: " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Removed " + str(inaccessible_count) + " inaccessible room(s). Remaining rooms: " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
# Update start_room_index after filtering (find start room in new array using value-based comparison) # Level 4: boss room is always index 0 in filtered list (first room was the root). Assign start/exit in level-4 block below.
# Non-level-4: update start_room_index after filtering
if level != 4:
start_room_index = -1 start_room_index = -1
for i in range(all_rooms.size()): for i in range(all_rooms.size()):
var room = all_rooms[i] var room = all_rooms[i]
@@ -222,7 +282,6 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
room.w == start_room_ref.w and room.h == start_room_ref.h: room.w == start_room_ref.w and room.h == start_room_ref.h:
start_room_index = i start_room_index = i
break break
if start_room_index == -1: if start_room_index == -1:
LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON) LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON)
start_room_index = 0 # Fallback start_room_index = 0 # Fallback
@@ -255,6 +314,8 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
if door_room1_reachable and door_room2_reachable: if door_room1_reachable and door_room2_reachable:
filtered_doors.append(door) filtered_doors.append(door)
else: else:
# Fill door and corridor with wall so we don't leave orphan corridors (corridor with no door)
_fill_door_tiles_as_wall(door, grid, tile_grid, map_size)
doors_removed += 1 doors_removed += 1
LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON)
@@ -262,24 +323,44 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
if doors_removed > 0: if doors_removed > 0:
LogManager.log("DungeonGenerator: Removed " + str(doors_removed) + " door(s) connected to inaccessible rooms. Remaining doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Removed " + str(doors_removed) + " door(s) connected to inaccessible rooms. Remaining doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON)
# Boss room for level 4 (used to exclude from fallout); empty for other levels
var boss_room_for_fallout: Dictionary = {}
# Find the farthest reachable room (now all rooms are reachable, but find farthest) # Find the farthest reachable room (now all rooms are reachable, but find farthest)
# Make sure we have at least 2 rooms (start and exit must be different)
# exit_room_index is already declared at function level # exit_room_index is already declared at function level
if all_rooms.size() < 2: if all_rooms.size() == 0:
LogManager.log_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
# Use start room as exit if only one room exists (shouldn't happen, but handle gracefully)
if all_rooms.size() == 1:
exit_room_index = 0
else:
# No rooms at all - this is a critical error
LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_DUNGEON) LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_DUNGEON)
return {} # Return empty dungeon return {} # Return empty dungeon
# Level 4: Boss room 0, exit room 1 (only 1 door: boss<->exit). SPAWN = one of the "other" rooms (index 2+), never boss or exit.
if level == 4:
var boss_room_index_l4 = 0
var exit_room_index_l4 = 1
# Start room MUST be one of the "other" rooms (index 2+), so player never spawns in boss or exit
var start_candidates = []
for i in range(2, all_rooms.size()):
start_candidates.append(i)
if start_candidates.is_empty():
# Fallback if we only have boss+exit (shouldn't happen - we place min 6 other rooms)
start_candidates.append(0)
start_room_index = start_candidates[rng.randi() % start_candidates.size()]
all_rooms[start_room_index].modifiers.append({"type": "START"})
exit_room_index = exit_room_index_l4
all_rooms[boss_room_index_l4].modifiers.append({"type": "BOSS"})
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
boss_room_for_fallout = all_rooms[boss_room_index_l4]
# CRITICAL: Exit room must have ONLY ONE door (to the boss). Remove any other doors that _connect_rooms may have added.
_remove_doors_from_exit_room_except_boss(all_rooms, all_doors, grid, tile_grid, map_size, exit_room_index, boss_room_index_l4)
LogManager.log("DungeonGenerator: Level 4 - Boss " + str(boss_room_index_l4) + ", exit " + str(exit_room_index) + " (1 door only), start " + str(start_room_index) + " (other room)", LogManager.CATEGORY_DUNGEON)
elif all_rooms.size() < 2:
LogManager.log_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
exit_room_index = 0
else: else:
exit_room_index = _find_farthest_room(all_rooms, start_room_index) exit_room_index = _find_farthest_room(all_rooms, start_room_index)
# Make sure exit room is different from start room # Make sure exit room is different from start room (non-level-4 path)
if level != 4:
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
if exit_room_index == start_room_index and all_rooms.size() > 1: if exit_room_index == start_room_index and all_rooms.size() > 1:
# If exit is same as start, find second farthest
var max_distance = 0 var max_distance = 0
var second_farthest = -1 var second_farthest = -1
for i in range(all_rooms.size()): for i in range(all_rooms.size()):
@@ -291,9 +372,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
second_farthest = i second_farthest = i
if second_farthest != -1: if second_farthest != -1:
exit_room_index = second_farthest exit_room_index = second_farthest
LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index), LogManager.CATEGORY_DUNGEON)
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index) + " position: " + str(all_rooms[exit_room_index].x) + "," + str(all_rooms[exit_room_index].y), LogManager.CATEGORY_DUNGEON)
# 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)
@@ -305,8 +384,10 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
_fill_decorated_tile_grid(grid, decorated_tile_grid, map_size, rng) _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 # 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) _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 # 7.45. Sprinkle fallout tiles on some floor (cracked/worn look); never in doors, corridors, in front of doors, start room, or boss room (level 4). Level 4 uses lower chance.
_render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng) _render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng, boss_room_for_fallout, level)
# 7.455. Cracked floor must never be on fallout tiles: clear cracked where fallout was placed
_clear_cracked_on_fallout(tile_grid, cracked_tile_grid, map_size)
# 7.46. Keep fallout tiles free from decorated layer (no decorated tiles on top of fallout) # 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) _clear_decorated_on_fallout(tile_grid, decorated_tile_grid, map_size)
@@ -340,7 +421,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
# 11. Place blocking doors on existing tile doors (after everything else is created) # 11. Place blocking doors on existing tile doors (after everything else is created)
# IMPORTANT: This must happen BEFORE placing enemies, so we know which rooms have monster spawner puzzles # IMPORTANT: This must happen BEFORE placing enemies, so we know which rooms have monster spawner puzzles
var blocking_doors_result = _place_blocking_doors(all_rooms, all_doors, grid, map_size, rng, start_room_index, exit_room_index) var blocking_doors_result = _place_blocking_doors(all_rooms, all_doors, grid, map_size, rng, start_room_index, exit_room_index, level)
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 {}
@@ -384,6 +465,15 @@ 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:
# Level 4: skip BOSS room (boss is placed separately)
var is_boss_room = false
if room.get("modifiers") is Array:
for mod in room.modifiers:
if mod is Dictionary and mod.get("type") == "BOSS":
is_boss_room = true
break
if is_boss_room:
continue
# CRITICAL: Skip rooms that have monster spawner puzzles (these will spawn enemies when player enters) # CRITICAL: Skip rooms that have monster spawner puzzles (these will spawn enemies when player enters)
var has_spawner_puzzle = false var has_spawner_puzzle = false
for spawner_room in rooms_with_spawner_puzzles: for spawner_room in rooms_with_spawner_puzzles:
@@ -397,17 +487,135 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
var room_enemies = _place_enemies_in_room(room, grid, map_size, rng, level) var room_enemies = _place_enemies_in_room(room, grid, map_size, rng, level)
all_enemies.append_array(room_enemies) all_enemies.append_array(room_enemies)
# 9.5. Level 4: add boss spider bat in BOSS room center (exit room has stairs and is after boss room)
if level == 4:
var boss_room = null
for room in all_rooms:
if room.get("modifiers") is Array:
for mod in room.modifiers:
if mod is Dictionary and mod.get("type") == "BOSS":
boss_room = room
break
if boss_room != null:
break
if boss_room != null:
var tile_size = 16
var center_x = (boss_room.x + boss_room.w / 2.0) * tile_size
var center_y = (boss_room.y + boss_room.h / 2.0) * tile_size
var boss_data = {
"type": "res://scenes/boss_spider_bat.tscn",
"position": Vector2(center_x, center_y),
"room": boss_room,
"max_health": 1200.0,
"move_speed": 60.0,
"damage": 15.0,
"is_boss": true
}
all_enemies.append(boss_data)
LogManager.log("DungeonGenerator: Level 4 - Added boss_spider_bat in BOSS room at " + str(boss_data.position), LogManager.CATEGORY_DUNGEON)
# 9.5. Place interactable objects in rooms (excluding start and exit rooms) # 9.5. Place interactable objects in rooms (excluding start and exit rooms)
# For pillar-switch rooms, collect switch tile(s) so we never place the pillar on the switch (would pre-solve the puzzle)
var all_interactable_objects = [] var all_interactable_objects = []
for i in range(all_rooms.size()): for i in range(all_rooms.size()):
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, tile_grid, map_size, all_doors, all_enemies, rng, room_puzzle_data) var exclude_switch_tiles: Array = []
for door_data in blocking_doors_array:
if door_data.get("puzzle_type") != "switch_pillar":
continue
var block_room = door_data.get("blocking_room") if door_data.get("blocking_room") is Dictionary else {}
if block_room.is_empty() or block_room.x != room.x or block_room.y != room.y or block_room.w != room.w or block_room.h != room.h:
continue
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
var st = Vector2i(int(door_data.switch_tile_x), int(door_data.switch_tile_y))
if not exclude_switch_tiles.has(st):
exclude_switch_tiles.append(st)
var room_objects = _place_interactable_objects_in_room(room, grid, tile_grid, map_size, all_doors, all_enemies, rng, room_puzzle_data, exclude_switch_tiles)
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; never on fallout tiles)
var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, map_size, rng) var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, tile_grid, map_size, rng)
# Level 4: crop dungeon to content (so final map is only as big as rooms + margin, not 512x512)
if level == 4:
var min_x = map_size.x
var min_y = map_size.y
var max_x = 0
var max_y = 0
for room in all_rooms:
min_x = mini(min_x, room.x)
min_y = mini(min_y, room.y)
max_x = maxi(max_x, room.x + room.w)
max_y = maxi(max_y, room.y + room.h)
for door in all_doors:
var dx = door.get("x", 0)
var dy = door.get("y", 0)
min_x = mini(min_x, dx)
min_y = mini(min_y, dy)
max_x = maxi(max_x, dx + door.get("w", 3))
max_y = maxi(max_y, dy + door.get("h", 2))
var margin = LEVEL4_CROP_MARGIN
var offset_x = maxi(0, min_x - margin)
var offset_y = maxi(0, min_y - margin)
max_x = mini(map_size.x, max_x + margin)
max_y = mini(map_size.y, max_y + margin)
var new_w = max_x - offset_x
var new_h = max_y - offset_y
# Build new grids
var new_grid = []
var new_tile_grid = []
var new_decorated_tile_grid = []
var new_cracked_tile_grid = []
for x in range(new_w):
new_grid.append([])
new_tile_grid.append([])
new_decorated_tile_grid.append([])
new_cracked_tile_grid.append([])
for y in range(new_h):
var ox = x + offset_x
var oy = y + offset_y
new_grid[x].append(grid[ox][oy] if ox < grid.size() and oy < grid[ox].size() else 0)
new_tile_grid[x].append(tile_grid[ox][oy] if ox < tile_grid.size() and oy < tile_grid[ox].size() else Vector2i(0, 0))
new_decorated_tile_grid[x].append(decorated_tile_grid[ox][oy] if ox < decorated_tile_grid.size() and oy < decorated_tile_grid[ox].size() else null)
new_cracked_tile_grid[x].append(cracked_tile_grid[ox][oy] if ox < cracked_tile_grid.size() and oy < cracked_tile_grid[ox].size() else false)
grid = new_grid
tile_grid = new_tile_grid
decorated_tile_grid = new_decorated_tile_grid
cracked_tile_grid = new_cracked_tile_grid
map_size = Vector2i(new_w, new_h)
var tile_size = 16
var world_offset = Vector2(offset_x * tile_size, offset_y * tile_size)
for room in all_rooms:
room.x -= offset_x
room.y -= offset_y
for door in all_doors:
door.x -= offset_x
door.y -= offset_y
if stairs_data.size() > 0:
stairs_data.x -= offset_x
stairs_data.y -= offset_y
if stairs_data.has("world_pos"):
stairs_data.world_pos -= world_offset
if entrance_data.size() > 0:
entrance_data.x -= offset_x
entrance_data.y -= offset_y
if entrance_data.has("world_pos"):
entrance_data.world_pos -= world_offset
var doors_to_shift = blocking_doors if blocking_doors is Array else blocking_doors.get("doors", [])
for door_data in doors_to_shift:
if door_data.has("switch_tile_x"): door_data.switch_tile_x -= offset_x
if door_data.has("switch_tile_y"): door_data.switch_tile_y -= offset_y
for e in all_enemies:
if e.has("position"): e.position -= world_offset
for t in all_traps:
if t.has("position"): t.position -= world_offset
for obj in all_interactable_objects:
if obj.has("position"): obj.position -= world_offset
for torch_data in all_torches:
if torch_data.has("position"): torch_data.position -= world_offset
LogManager.log("DungeonGenerator: Level 4 - Cropped to " + str(new_w) + "x" + str(new_h) + " (offset " + str(offset_x) + "," + str(offset_y) + ")", LogManager.CATEGORY_DUNGEON)
# NOTE: Stairs placement was moved earlier (step 7.5, before torches) to prevent torch overlap # NOTE: Stairs placement was moved earlier (step 7.5, before torches) to prevent torch overlap
# NOTE: Blocking doors placement was moved earlier (step 11, before enemy placement) to identify spawner puzzle rooms # NOTE: Blocking doors placement was moved earlier (step 11, before enemy placement) to identify spawner puzzle rooms
@@ -439,11 +647,12 @@ func _set_floor(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vecto
grid[x][y] = 1 # Floor grid[x][y] = 1 # Floor
tile_grid[x][y] = FLOOR_BASE tile_grid[x][y] = FLOOR_BASE
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, max_room_size_param: int = MAX_ROOM_SIZE, _level: int = 1) -> Dictionary:
var attempts = 20 # Level 4: more attempts per call because the large boss room makes placement harder
var attempts = 50 if _level == 4 else 20
while attempts > 0: while attempts > 0:
var w = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE) var w = rng.randi_range(MIN_ROOM_SIZE, max_room_size_param)
var h = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE) var h = rng.randi_range(MIN_ROOM_SIZE, max_room_size_param)
# Try all four sides of the source room # Try all four sides of the source room
var sides = ["N", "S", "E", "W"] var sides = ["N", "S", "E", "W"]
@@ -603,9 +812,19 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
# Create corridor (1 tile wide) - base floor (0,15) # Create corridor (1 tile wide) - base floor (0,15)
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 corridor_y >= 0 and corridor_y < map_size.y:
grid[x][door_y + 1] = 3 # Corridor (middle row of door) grid[x][corridor_y] = 3 # Corridor (middle row of door)
tile_grid[x][door_y + 1] = FLOOR_BASE tile_grid[x][corridor_y] = FLOOR_BASE
# Corridor walls: 2 tiles above = WALL_TOP_UPPER, WALL_TOP_LOWER; 2 tiles below = WALL_BOTTOM_UPPER, WALL_BOTTOM_LOWER (only where grid is wall)
if x >= 0 and x < map_size.x:
if corridor_y - 2 >= 0 and grid[x][corridor_y - 2] == 0:
tile_grid[x][corridor_y - 2] = WALL_TOP_UPPER
if corridor_y - 1 >= 0 and grid[x][corridor_y - 1] == 0:
tile_grid[x][corridor_y - 1] = WALL_TOP_LOWER
if corridor_y + 1 < map_size.y and grid[x][corridor_y + 1] == 0:
tile_grid[x][corridor_y + 1] = WALL_BOTTOM_UPPER
if corridor_y + 2 < map_size.y and grid[x][corridor_y + 2] == 0:
tile_grid[x][corridor_y + 2] = WALL_BOTTOM_LOWER
# 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
@@ -691,9 +910,19 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
# Create corridor (1 tile wide) - base floor (0,15) # Create corridor (1 tile wide) - base floor (0,15)
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 corridor_x >= 0 and corridor_x < map_size.x and y >= 0 and y < map_size.y:
grid[door_x + 1][y] = 3 # Corridor (middle column of door) grid[corridor_x][y] = 3 # Corridor (middle column of door)
tile_grid[door_x + 1][y] = FLOOR_BASE tile_grid[corridor_x][y] = FLOOR_BASE
# Corridor walls: left 2 tiles = WALL_LEFT_LEFT, WALL_LEFT_RIGHT; right 2 tiles = WALL_RIGHT_LEFT, WALL_RIGHT_RIGHT (only where grid is wall)
if y >= 0 and y < map_size.y:
if corridor_x - 2 >= 0 and grid[corridor_x - 2][y] == 0:
tile_grid[corridor_x - 2][y] = WALL_LEFT_LEFT
if corridor_x - 1 >= 0 and grid[corridor_x - 1][y] == 0:
tile_grid[corridor_x - 1][y] = WALL_LEFT_RIGHT
if corridor_x + 1 < map_size.x and grid[corridor_x + 1][y] == 0:
tile_grid[corridor_x + 1][y] = WALL_RIGHT_LEFT
if corridor_x + 2 < map_size.x and grid[corridor_x + 2][y] == 0:
tile_grid[corridor_x + 2][y] = WALL_RIGHT_RIGHT
# 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
@@ -1057,52 +1286,6 @@ func _find_reachable_rooms(start_room: Dictionary, _all_rooms: Array, all_doors:
return reachable return reachable
func _add_hole_to_room(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator):
# Add a hole (minimum 9x9 tiles) somewhere in the room
# Holes should only be placed in the floor area (not in walls)
# Floor area is from room.x + 2 to room.x + room.w - 2, and room.y + 2 to room.y + room.h - 2
var floor_min_x = room.x + 2
var floor_min_y = room.y + 2
var floor_max_x = room.x + room.w - 2
var floor_max_y = room.y + room.h - 2
var floor_w = floor_max_x - floor_min_x
var floor_h = floor_max_y - floor_min_y
if floor_w < MIN_HOLE_SIZE or floor_h < MIN_HOLE_SIZE:
return # Room too small for hole
var hole_size = rng.randi_range(MIN_HOLE_SIZE, min(floor_w, floor_h, 12))
# Position hole within floor area (with 1 tile margin from floor edges)
var max_x = floor_max_x - hole_size
var max_y = floor_max_y - hole_size
if max_x < floor_min_x or max_y < floor_min_y:
return # Floor area too small for hole
var hole_x = rng.randi_range(floor_min_x, max_x)
var hole_y = rng.randi_range(floor_min_y, max_y)
# 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
for x in range(hole_x, hole_x + hole_size):
for y in range(hole_y, hole_y + hole_size):
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
# Only create hole if it's currently a floor tile
if grid[x][y] == 1: # Floor
grid[x][y] = 0 # Wall
# Fallout corner tiles for hole corners; rest get center (cleanup will set edges)
if x == hole_x and y == hole_y:
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_LEFT
elif x == hole_x + hole_size - 1 and y == hole_y:
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_RIGHT
elif x == hole_x and y == hole_y + hole_size - 1:
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_LEFT
elif x == hole_x + hole_size - 1 and y == hole_y + hole_size - 1:
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_RIGHT
else:
tile_grid[x][y] = FALLOUT_CENTER
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]
var max_distance = 0 var max_distance = 0
@@ -1119,6 +1302,171 @@ func _find_farthest_room(all_rooms: Array, start_index: int) -> int:
return farthest_index return farthest_index
func _find_largest_room_index(all_rooms: Array) -> int:
# Return index of the room with the largest area (w*h). Used for level 4 so boss gets the big room.
var best_index = 0
var best_area = all_rooms[0].w * all_rooms[0].h
for i in range(1, all_rooms.size()):
var area = all_rooms[i].w * all_rooms[i].h
if area > best_area:
best_area = area
best_index = i
LogManager.log("DungeonGenerator: Level 4 - Largest room is index " + str(best_index) + " (size " + str(all_rooms[best_index].w) + "x" + str(all_rooms[best_index].h) + ", area " + str(best_area) + ")", LogManager.CATEGORY_DUNGEON)
return best_index
func _find_exit_room_after_boss_level4(all_rooms: Array, all_doors: Array, boss_room_index: int, start_room_index: int) -> int:
# Find a room that is adjacent to the boss room (connected by a door) to use as the exit room (stairs).
# Prefer a room that has only one connection (to the boss room). If none, fallback to boss room (stairs in same room).
var boss_room = all_rooms[boss_room_index]
var candidates = [] # {index, door_count}
for i in range(all_rooms.size()):
if i == boss_room_index or i == start_room_index:
continue
var room = all_rooms[i]
var doors_to_boss = 0
for door in all_doors:
var r1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
var r2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
var conn_boss = (r1 and r1.x == boss_room.x and r1.y == boss_room.y and r1.w == boss_room.w and r1.h == boss_room.h) or (r2 and r2.x == boss_room.x and r2.y == boss_room.y and r2.w == boss_room.w and r2.h == boss_room.h)
var conn_room = (r1 and r1.x == room.x and r1.y == room.y and r1.w == room.w and r1.h == room.h) or (r2 and r2.x == room.x and r2.y == room.y and r2.w == room.w and r2.h == room.h)
if conn_boss and conn_room:
doors_to_boss += 1
if doors_to_boss > 0:
var total_doors = 0
for door in all_doors:
var r1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
var r2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
if (r1 and r1.x == room.x and r1.y == room.y) or (r2 and r2.x == room.x and r2.y == room.y):
total_doors += 1
candidates.append({"index": i, "doors_to_boss": doors_to_boss, "total_doors": total_doors})
if candidates.is_empty():
LogManager.log("DungeonGenerator: Level 4 - No room adjacent to boss room, using boss room as exit", LogManager.CATEGORY_DUNGEON)
return boss_room_index
# Prefer room with only one door (only connected to boss)
candidates.sort_custom(func(a, b): return a.total_doors < b.total_doors)
var best = candidates[0].index
LogManager.log("DungeonGenerator: Level 4 - Exit room after boss is index " + str(best), LogManager.CATEGORY_DUNGEON)
return best
func _room_equals(a: Dictionary, b: Dictionary) -> bool:
return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h
func _fill_door_tiles_as_wall(door: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i) -> void:
# Revert door and corridor tiles to wall (grid 0, generic wall tile) so we can "remove" the door.
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
if not r1 or not r2:
return
var door_dir = door.get("dir", "E")
var dx = door.get("x", 0)
var dy = door.get("y", 0)
var wall_tile = WALL_TOP_LOWER
if door_dir == "E":
# Horizontal: left door 2x3 at (dx, dy), right door 2x3 at (r2.x, dy), corridor one row at y=dy+1
# Left door tiles
for door_dx in range(2):
for door_dy in range(3):
var x = dx + door_dx
var y = dy + door_dy
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
grid[x][y] = 0
tile_grid[x][y] = wall_tile
# Right door tiles
for door_dx in range(2):
for door_dy in range(3):
var x = r2.x + door_dx
var y = dy + door_dy
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
grid[x][y] = 0
tile_grid[x][y] = wall_tile
# Corridor row (between the two room walls)
var cx_start = r1.x + r1.w
var cx_end = r2.x
for cx in range(cx_start, cx_end):
var cy = dy + 1
if cx >= 0 and cx < map_size.x and cy >= 0 and cy < map_size.y:
grid[cx][cy] = 0
tile_grid[cx][cy] = wall_tile
else:
# Vertical (S): top door 3x2 at (dx, dy), bottom door 3x2 at (dx, r2.y), corridor one column at x=dx+1
# Top door
for door_dx in range(3):
for door_dy in range(2):
var x = dx + door_dx
var y = dy + door_dy
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
grid[x][y] = 0
tile_grid[x][y] = wall_tile
# Bottom door
for door_dx in range(3):
for door_dy in range(2):
var x = dx + door_dx
var y = r2.y + door_dy
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
grid[x][y] = 0
tile_grid[x][y] = wall_tile
# Corridor column
var cy_start = r1.y + r1.h
var cy_end = r2.y
for cy in range(cy_start, cy_end):
var cx = dx + 1
if cx >= 0 and cx < map_size.x and cy >= 0 and cy < map_size.y:
grid[cx][cy] = 0
tile_grid[cx][cy] = wall_tile
func _remove_doors_from_exit_room_except_boss(all_rooms: Array, all_doors: Array, grid: Array, tile_grid: Array, map_size: Vector2i, exit_room_index: int, boss_room_index: int) -> void:
# Level 4: exit room should ideally only connect to boss. We remove doors that connect exit to a non-boss room
# ONLY when that other room has at least one other door (so we never isolate a room - e.g. entrance must stay connected).
var to_remove: Array = []
# Count doors per room (how many doors each room index has)
var door_count: Array = []
door_count.resize(all_rooms.size())
for j in range(all_rooms.size()):
door_count[j] = 0
for door in all_doors:
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
if not r1 or not r2:
continue
for j in range(all_rooms.size()):
if _room_equals(r1, all_rooms[j]):
door_count[j] += 1
if _room_equals(r2, all_rooms[j]):
door_count[j] += 1
for i in range(all_doors.size()):
var door = all_doors[i]
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
if not r1 or not r2:
continue
var r1_idx = -1
var r2_idx = -1
for j in range(all_rooms.size()):
if _room_equals(r1, all_rooms[j]):
r1_idx = j
if _room_equals(r2, all_rooms[j]):
r2_idx = j
var is_boss_exit_door = (r1_idx == boss_room_index and r2_idx == exit_room_index) or (r1_idx == exit_room_index and r2_idx == boss_room_index)
if is_boss_exit_door:
continue
var door_has_exit = (r1_idx == exit_room_index or r2_idx == exit_room_index)
if not door_has_exit:
continue
# Other room (the one that is not exit) must have more than 1 door total, else we would isolate it
var other_idx = r2_idx if r1_idx == exit_room_index else r1_idx
if other_idx < 0 or other_idx >= door_count.size():
continue
if door_count[other_idx] <= 1:
continue # Do NOT remove: would isolate that room (e.g. only path to boss)
to_remove.append(i)
# Remove from end so indices stay valid
for i in range(to_remove.size() - 1, -1, -1):
var idx = to_remove[i]
_fill_door_tiles_as_wall(all_doors[idx], grid, tile_grid, map_size)
all_doors.remove_at(idx)
if to_remove.size() > 0:
LogManager.log("DungeonGenerator: Level 4 - Removed " + str(to_remove.size()) + " door(s) from exit room (kept doors that would have isolated a room)", LogManager.CATEGORY_DUNGEON)
func _find_farthest_room_from_list(all_rooms: Array, start_index: int, reachable_rooms: Array) -> int: func _find_farthest_room_from_list(all_rooms: Array, start_index: int, reachable_rooms: Array) -> int:
# Find the farthest room from the start room, but only from the reachable rooms list # Find the farthest room from the start room, but only from the reachable rooms list
var start_room = all_rooms[start_index] var start_room = all_rooms[start_index]
@@ -1462,9 +1810,11 @@ func _is_tile_blocked_for_fallout(x: int, y: int, _grid: Array, _map_size: Vecto
return true return true
return false return false
func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator): func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator, boss_room: Dictionary = {}, dungeon_level: int = 1):
# Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, or in start room # Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, start room, or boss room. Level 4: much less fallout.
const CHANCE = 0.08 var CHANCE = 0.08
if dungeon_level == 4:
CHANCE = 0.02 # Level 4: only a light sprinkle, no insanely much
for x in range(map_size.x): for x in range(map_size.x):
for y in range(map_size.y): for y in range(map_size.y):
if grid[x][y] != 1: if grid[x][y] != 1:
@@ -1473,6 +1823,8 @@ func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, al
continue continue
if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room): if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room):
continue # No fallout tiles in start room continue # No fallout tiles in start room
if not boss_room.is_empty() and _is_tile_in_room_interior(x, y, boss_room):
continue # No fallout tiles in boss room (level 4)
if rng.randf() >= CHANCE: if rng.randf() >= CHANCE:
continue continue
tile_grid[x][y] = _get_fallout_tile_for_floor(grid, map_size, x, y) tile_grid[x][y] = _get_fallout_tile_for_floor(grid, map_size, x, y)
@@ -1483,6 +1835,14 @@ func _is_fallout_atlas(tile: Vector2i) -> bool:
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_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 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_cracked_on_fallout(tile_grid: Array, cracked_tile_grid: Array, map_size: Vector2i):
# Cracked floors must only show on non-fallout tiles; clear cracked wherever there is fallout
for x in range(map_size.x):
for y in range(map_size.y):
if x < cracked_tile_grid.size() and y < cracked_tile_grid[x].size():
if _is_fallout_atlas(tile_grid[x][y]):
cracked_tile_grid[x][y] = false
func _clear_decorated_on_fallout(tile_grid: Array, decorated_tile_grid: Array, map_size: Vector2i): func _clear_decorated_on_fallout(tile_grid: Array, decorated_tile_grid: Array, map_size: Vector2i):
for x in range(map_size.x): for x in range(map_size.x):
for y in range(map_size.y): for y in range(map_size.y):
@@ -2096,8 +2456,9 @@ 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, tile_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 = {}, exclude_switch_tiles: Array = []) -> Array:
# Place interactable objects in a room # Place interactable objects in a room
# exclude_switch_tiles: Array of Vector2i (tile_x, tile_y) to never place objects on (e.g. pillar switch tile - would pre-solve puzzle)
# 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
# Large rooms (11-12 tiles): 0-8 objects # Large rooms (11-12 tiles): 0-8 objects
@@ -2182,6 +2543,14 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
for x in range(min_x, max_x + 1): # +1 because range is exclusive at end for x in range(min_x, max_x + 1): # +1 because range is exclusive at end
for y in range(min_y, max_y + 1): for y in range(min_y, max_y + 1):
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y: if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
# Never place objects on a floor switch tile (pillar would pre-solve the puzzle)
var is_switch_tile = false
for st in exclude_switch_tiles:
if st is Vector2i and st.x == x and st.y == y:
is_switch_tile = true
break
if is_switch_tile:
continue
# Check if it's a floor tile # Check if it's a floor tile
if grid[x][y] == 1: # Floor if grid[x][y] == 1: # Floor
# CRITICAL: Interactable objects are 16x16 pixels with origin at (0,0) (top-left) # CRITICAL: Interactable objects are 16x16 pixels with origin at (0,0) (top-left)
@@ -2207,6 +2576,13 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
for x in range(min_x, max_x + 1): for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1): for y in range(min_y, max_y + 1):
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y: if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
var is_switch_tile = false
for st in exclude_switch_tiles:
if st is Vector2i and st.x == x and st.y == y:
is_switch_tile = true
break
if is_switch_tile:
continue
if grid[x][y] == 1: # Floor if grid[x][y] == 1: # Floor
var world_x = x * tile_size + 8 var world_x = x * tile_size + 8
var world_y = y * tile_size + 8 var world_y = y * tile_size + 8
@@ -2252,26 +2628,31 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
# Skip Pillar type for remaining objects (already placed one) # Skip Pillar type for remaining objects (already placed one)
while object_type_data.type == "Pillar": while object_type_data.type == "Pillar":
object_type_data = object_types[rng.randi() % object_types.size()] object_type_data = object_types[rng.randi() % object_types.size()]
var obj_entry = {
objects.append({
"type": object_type_data.type, "type": object_type_data.type,
"setup_function": object_type_data.setup, "setup_function": object_type_data.setup,
"position": valid_positions[positions_index], "position": valid_positions[positions_index],
"room": room "room": room
}) }
if object_type_data.type == "Chest" and rng.randf() < 0.4:
obj_entry["hidden"] = true
objects.append(obj_entry)
positions_index += 1 positions_index += 1
else: else:
# Normal placement: no pillar requirement # Normal placement: no pillar requirement
for i in range(min(num_objects, valid_positions.size())): for i in range(min(num_objects, valid_positions.size())):
var object_type_data = object_types[rng.randi() % object_types.size()] var object_type_data = object_types[rng.randi() % object_types.size()]
var position = valid_positions[i] var position = valid_positions[i]
var obj_entry = {
objects.append({
"type": object_type_data.type, "type": object_type_data.type,
"setup_function": object_type_data.setup, "setup_function": object_type_data.setup,
"position": position, "position": position,
"room": room "room": room
}) }
# Chests can be hidden (invisible until perception roll detects them) ~40% chance so SfxSecretFound is heard
if object_type_data.type == "Chest" and rng.randf() < 0.4:
obj_entry["hidden"] = true
objects.append(obj_entry)
# If an interactable spawns on a fallout tile, replace that tile with normal floor # If an interactable spawns on a fallout tile, replace that tile with normal floor
for obj in objects: for obj in objects:
@@ -2414,7 +2795,7 @@ func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_room
return rooms_before_door return rooms_before_door
func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, start_room_index: int, exit_room_index: int) -> Dictionary: func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, start_room_index: int, exit_room_index: int, level: int = 1) -> Dictionary:
# Place blocking doors on existing tile doors # Place blocking doors on existing tile doors
# Returns array of blocking door data dictionaries # Returns array of blocking door data dictionaries
var blocking_doors = [] var blocking_doors = []
@@ -2430,14 +2811,40 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
# Track which rooms have puzzles and which doors are already assigned # Track which rooms have puzzles and which doors are already assigned
var _rooms_with_puzzles = {} # room -> true var _rooms_with_puzzles = {} # room -> true
var assigned_doors = [] # Doors already assigned to a room puzzle var assigned_doors = [] # Doors already assigned to a room puzzle
var room_puzzle_data = {} # room -> {type: "switch" or "enemy", doors: []} var room_puzzle_data = {} # room -> {type: "switch" or "enemy" or "boss", doors: []}
# STEP 0: Level 4 - BOSS room (not exit room): door(s) leading INTO boss room close and require boss defeat; exit is in a different room after boss
if level == 4:
var boss_room = null
for room in all_rooms:
if room.get("modifiers") is Array:
for mod in room.modifiers:
if mod is Dictionary and mod.get("type") == "BOSS":
boss_room = room
break
if boss_room != null:
break
if boss_room != null:
# All doors connected to the boss room (either side) must get blocking doors
var doors_into_boss = []
for door in all_doors:
var door_room1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
var door_room2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
var conn_boss = (door_room1 and door_room1.x == boss_room.x and door_room1.y == boss_room.y and door_room1.w == boss_room.w and door_room1.h == boss_room.h) or (door_room2 and door_room2.x == boss_room.x and door_room2.y == boss_room.y and door_room2.w == boss_room.w and door_room2.h == boss_room.h)
if conn_boss:
doors_into_boss.append(door)
if doors_into_boss.size() > 0:
room_puzzle_data[boss_room] = {"type": "boss", "doors": doors_into_boss}
for d in doors_into_boss:
assigned_doors.append(d)
LogManager.log("DungeonGenerator: Level 4 - Boss room has " + str(doors_into_boss.size()) + " door(s) that will close behind player", LogManager.CATEGORY_DUNGEON)
# STEP 1: For each room (except start/exit), randomly decide if it has a door-puzzle # STEP 1: For each room (except start/exit), randomly decide if it has a door-puzzle
var puzzle_room_chance = 0.4 # 40% chance per room var puzzle_room_chance = 0.4 # 40% chance per room
LogManager.log("DungeonGenerator: Assigning puzzles to rooms (" + str(all_rooms.size()) + " total rooms, excluding start/exit)", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Assigning puzzles to rooms (" + str(all_rooms.size()) + " total rooms, excluding start/exit)", LogManager.CATEGORY_DUNGEON)
for i in range(all_rooms.size()): for i in range(all_rooms.size()):
if i == start_room_index or i == exit_room_index: if i == start_room_index or i == exit_room_index:
continue # Skip start and exit rooms continue # Skip start and exit rooms (exit already handled as boss on level 4)
var room = all_rooms[i] var room = all_rooms[i]
@@ -2524,16 +2931,18 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
room_index = j room_index = j
break break
if room_index == start_room_index or room_index == exit_room_index: var puzzle_info = room_puzzle_data[room]
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start/exit room! Skipping.", LogManager.CATEGORY_DUNGEON) if room_index == start_room_index:
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start room! Skipping.", LogManager.CATEGORY_DUNGEON)
continue
if room_index == exit_room_index and puzzle_info.type != "boss":
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for exit room (non-boss)! Skipping.", LogManager.CATEGORY_DUNGEON)
continue continue
# CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check) # CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check)
if room_index == -1: if room_index == -1:
LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") not found in all_rooms! Skipping.", LogManager.CATEGORY_DUNGEON) LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") not found in all_rooms! Skipping.", LogManager.CATEGORY_DUNGEON)
continue continue
var puzzle_info = room_puzzle_data[room]
var doors_in_room = puzzle_info.doors # Doors that are IN this puzzle room (lead OUT OF it) var doors_in_room = puzzle_info.doors # Doors that are IN this puzzle room (lead OUT OF it)
var puzzle_type = puzzle_info.type var puzzle_type = puzzle_info.type
@@ -2593,6 +3002,15 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
LogManager.log("DungeonGenerator: Created enemy spawner puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - spawner at " + str(spawner_data.position), LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Created enemy spawner puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - spawner at " + str(spawner_data.position), LogManager.CATEGORY_DUNGEON)
else: else:
LogManager.log("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON)
elif puzzle_type == "boss":
# Boss room: no switch or spawner; door closes on entry and opens when boss (pre-placed enemy) is defeated
puzzle_element_created = true
puzzle_element_data = {
"type": "boss",
"blocking_room": room
}
door_type = "StoneDoor" # Boss room always uses StoneDoor
LogManager.log("DungeonGenerator: Created boss room puzzle for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON)
# CRITICAL SAFETY CHECK: Only create blocking doors if puzzle element was successfully created # CRITICAL SAFETY CHECK: Only create blocking doors if puzzle element was successfully created
if not puzzle_element_created: if not puzzle_element_created:
@@ -2705,8 +3123,10 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
"puzzle_type": puzzle_type # "switch_walk", "switch_pillar", or "enemy" "puzzle_type": puzzle_type # "switch_walk", "switch_pillar", or "enemy"
} }
# Store puzzle room as room1 for blocking doors # Store puzzle room as room1 for blocking doors; room2 is the other room (so room_trigger finds door in puzzle room)
door_data.original_room1 = room # Puzzle room is always room1 for blocking doors door_data.original_room1 = room # Puzzle room is always room1 for blocking doors
if puzzle_is_room2 and ("room1" in door and door.room1 and not door.room1.is_empty()):
door_data.original_room2 = door.room1 # Room you're coming from (so door.room1/room2 are correct in game_world)
LogManager.log("DungeonGenerator: Creating blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open_tile: (" + str(open_tile_x) + "," + str(open_tile_y) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Creating blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open_tile: (" + str(open_tile_x) + "," + str(open_tile_y) + ")", LogManager.CATEGORY_DUNGEON)
@@ -2738,6 +3158,11 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
door_data.requires_enemies = true door_data.requires_enemies = true
door_has_valid_puzzle = true door_has_valid_puzzle = true
LogManager.log("DungeonGenerator: Added enemy spawner data to door - spawner at (" + str(puzzle_element_data.spawner_data.tile_x) + ", " + str(puzzle_element_data.spawner_data.tile_y) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("DungeonGenerator: Added enemy spawner data to door - spawner at (" + str(puzzle_element_data.spawner_data.tile_x) + ", " + str(puzzle_element_data.spawner_data.tile_y) + ")", LogManager.CATEGORY_DUNGEON)
elif puzzle_element_data.has("type") and puzzle_element_data.type == "boss":
door_data.requires_enemies = true
door_data.boss_room_door = true # Door checks all enemies in room (pre-placed boss), not just spawner
door_has_valid_puzzle = true
LogManager.log("DungeonGenerator: Added boss room door - requires_enemies (boss)", LogManager.CATEGORY_DUNGEON)
# CRITICAL SAFETY CHECK: Only add door to blocking doors if it has valid puzzle element # CRITICAL SAFETY CHECK: Only add door to blocking doors if it has valid puzzle element
if not door_has_valid_puzzle: if not door_has_valid_puzzle:
@@ -3049,8 +3474,8 @@ func _determine_door_direction_for_puzzle_room(door: Dictionary, puzzle_room: Di
else: else:
return "Right" # Door is right of puzzle room center - door is on right wall return "Right" # Door is right of puzzle room center - door is on right wall
func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_index: int, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Array: func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_index: int, grid: Array, tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Array:
# Place 1-2 traps in the dungeon (not in start or exit rooms) # Place 1-2 traps in the dungeon (not in start or exit rooms; never on fallout tiles)
var traps = [] var traps = []
var tile_size = 16 var tile_size = 16
@@ -3093,9 +3518,13 @@ func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_
var world_x = room.x + local_x var world_x = room.x + local_x
var world_y = room.y + local_y var world_y = room.y + local_y
# Check if position is valid (floor tile, not blocked) # Check if position is valid (floor tile, not blocked, not fallout)
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
# Never place trap on a fallout tile
if world_x < tile_grid.size() and world_y < tile_grid[world_x].size() and _is_fallout_atlas(tile_grid[world_x][world_y]):
attempts -= 1
continue
# 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

@@ -54,9 +54,18 @@ var anim_speed: float = 0.15 # Seconds per frame
@onready var collision_shape = get_node_or_null("CollisionShape2D") @onready var collision_shape = get_node_or_null("CollisionShape2D")
func _ready(): func _ready():
# Capture max_health set by spawn data (game_world sets it before add_child); _initialize_character_stats overwrites it
var spawn_max_health = max_health
# Initialize CharacterStats for RPG system # Initialize CharacterStats for RPG system
_initialize_character_stats() _initialize_character_stats()
# If spawn/scene set a higher max_health (e.g. boss with 1200), apply it to character_stats so take_damage uses it
if spawn_max_health > 0 and spawn_max_health > character_stats.maxhp:
var base_max = (character_stats.baseStats.end + character_stats.get_pass("end")) * 3.0 + character_stats.get_pass("maxhp")
character_stats.bonusmaxhp = spawn_max_health - base_max
character_stats.hp = character_stats.maxhp
max_health = character_stats.maxhp
current_health = character_stats.hp
else:
current_health = max_health current_health = max_health
add_to_group("enemy") add_to_group("enemy")
@@ -73,6 +82,19 @@ func _ready():
# Walls are on layer 7 (bit 6 = 64), not layer 4! # Walls are on layer 7 (bit 6 = 64), not layer 4!
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)
# Ensure SfxFallout exists for all enemies (LA_Enemy_Fall.wav when falling into fallout)
var fall_sfx = get_node_or_null("SfxFallout")
if fall_sfx == null:
fall_sfx = AudioStreamPlayer2D.new()
fall_sfx.name = "SfxFallout"
add_child(fall_sfx)
var stream = load("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream
if stream:
fall_sfx.stream = stream
fall_sfx.attenuation = 7.0
fall_sfx.panning_strength = 1.14
fall_sfx.bus = "Sfx"
# Initialize CharacterStats for this enemy # Initialize CharacterStats for this enemy
# Override in subclasses to set specific baseStats # Override in subclasses to set specific baseStats
func _initialize_character_stats(): func _initialize_character_stats():
@@ -127,69 +149,8 @@ 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 # Fallout: humanoid sinks like player; slime/spider/rat rotate 45 + scale then die. Subclasses that override _physics_process (e.g. spider) must call _check_and_handle_fallout(delta) and return if true.
if fallout_state: if _check_and_handle_fallout(delta):
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 return
# Update attack timer # Update attack timer
@@ -211,8 +172,9 @@ 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) # All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back)
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and velocity.length_squared() > 1.0: # Skip avoidance during knockback so thrown objects (barrel, box) can knock enemies into fallout tiles
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and not is_knocked_back and velocity.length_squared() > 1.0:
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 and game_world.has_method("_is_position_on_fallout_tile"): if game_world and game_world.has_method("_is_position_on_fallout_tile"):
var step = velocity.normalized() * 18.0 var step = velocity.normalized() * 18.0
@@ -257,6 +219,10 @@ func _physics_process(delta):
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 and game_world.has_method("_sync_enemy_damage_visual"): if game_world and game_world.has_method("_sync_enemy_damage_visual"):
game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, global_position, false]) game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, global_position, false])
# Sync boss health so joiner's HUD bar updates
if has_meta("is_boss") and get_meta("is_boss") and game_world and game_world.has_method("_rpc_to_ready_peers") and game_world.has_method("_sync_boss_health"):
var max_hp = character_stats.maxhp if character_stats else max_health
game_world._rpc_to_ready_peers("_sync_boss_health", [enemy_name, enemy_index, current_health, max_hp])
# Animate burn visual if it's a sprite (only on authority/server) # Animate burn visual if it's a sprite (only on authority/server)
if is_multiplayer_authority() and burn_debuff_visual and is_instance_valid(burn_debuff_visual): if is_multiplayer_authority() and burn_debuff_visual and is_instance_valid(burn_debuff_visual):
@@ -278,26 +244,93 @@ func _physics_process(delta):
burn_damage_timer = 0.0 burn_damage_timer = 0.0
_remove_burn_debuff() _remove_burn_debuff()
# Sync position and animation to clients (only server sends) # Sync position and animation to clients (only server sends). Also call from subclasses that override _physics_process (e.g. spider).
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): _send_position_sync_to_clients()
# Get state value if enemy has a state variable (for bats/slimes)
# Call this from base _physics_process or from subclasses that override _physics_process (e.g. enemy_spider) so position sync is always sent.
func _send_position_sync_to_clients() -> void:
if not multiplayer.has_multiplayer_peer() or not is_multiplayer_authority():
return
var state_val = -1 var state_val = -1
if "state" in self: if "state" in self:
state_val = get("state") as int state_val = get("state") as int
# Only send RPC if we're in the scene tree if not is_inside_tree():
if is_inside_tree(): return
# Get enemy name/index for identification
var enemy_name = name var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
# Use game_world to send RPC instead of rpc() on node instance
# This avoids node path resolution issues when clients haven't spawned yet
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 and game_world.has_method("_sync_enemy_position"): if game_world and game_world.has_method("_broadcast_enemy_position"):
# Send via game_world using enemy name/index and position for identification game_world._broadcast_enemy_position(enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val)
elif game_world and game_world.has_method("_sync_enemy_position"):
game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val]) game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val])
# Removed fallback rpc() call - it causes node path resolution errors
# If game_world is not available, skip sync (will sync next frame) # Returns true if we're in a fallout state and caller should return (used by base _physics_process and by spider which overrides _physics_process).
func _check_and_handle_fallout(delta: float) -> bool:
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 true
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
if "humanoid_type" in self:
current_direction = Direction.DOWN
rotation = deg_to_rad(45.0)
if has_method("_set_animation"):
_set_animation("FALL")
move_and_slide()
return true
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 true
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
rotation = deg_to_rad(45.0)
move_and_slide()
return true
var gw = get_tree().get_first_node_in_group("game_world")
var on_fallout = false
if position_z <= 0.0 and gw and 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:
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
current_direction = Direction.DOWN
rotation = deg_to_rad(45.0)
if has_method("_set_animation"):
_set_animation("FALL")
var fall_sfx_node = get_node_or_null("SfxFallout")
if fall_sfx_node:
fall_sfx_node.play()
else:
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 true
else:
# Slime-like / spider / rat: rotate 45 and scale down then die
fallout_defeat_started = true
fallout_scale_progress = 1.0
rotation = deg_to_rad(45.0)
var fall_sfx_slime = get_node_or_null("SfxFallout")
if fall_sfx_slime:
fall_sfx_slime.play()
move_and_slide()
return true
return false
func _ai_behavior(_delta): func _ai_behavior(_delta):
# Override in subclasses # Override in subclasses
@@ -528,6 +561,12 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals
else: else:
# Fallback: try direct RPC (may fail if node path doesn't match) # Fallback: try direct RPC (may fail if node path doesn't match)
_sync_damage_visual.rpc(actual_damage, from_position, is_critical) _sync_damage_visual.rpc(actual_damage, from_position, is_critical)
# Sync boss health so joiner's HUD boss bar updates
if has_meta("is_boss") and get_meta("is_boss") and game_world and game_world.has_method("_rpc_to_ready_peers") and game_world.has_method("_sync_boss_health"):
var max_hp = max_health
if character_stats:
max_hp = character_stats.maxhp
game_world._rpc_to_ready_peers("_sync_boss_health", [enemy_name, enemy_index, current_health, max_hp])
if current_health <= 0: if current_health <= 0:
# Prevent multiple death triggers # Prevent multiple death triggers
@@ -582,7 +621,7 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo
else: else:
damage_label.label = str(int(amount)) damage_label.label = str(int(amount))
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red
damage_label.z_index = 5 damage_label.z_index = 50 if (has_meta("is_boss") and get_meta("is_boss")) else 5 # Boss: draw above sprite
# Calculate direction from attacker (slight upward variation) # Calculate direction from attacker (slight upward variation)
var direction_from_attacker = (global_position - from_position).normalized() var direction_from_attacker = (global_position - from_position).normalized()

View File

@@ -8,6 +8,9 @@ enum HandState {HIDDEN, EMERGING, IDLE, GRABBING}
var state: HandState = HandState.HIDDEN var state: HandState = HandState.HIDDEN
var players_in_interest: Array[Node] = [] var players_in_interest: Array[Node] = []
# Perception detection (like traps): once revealed, show blue effect until hand emerges
var is_detected: bool = false
var player_detection_attempts: Dictionary = {} # peer_id -> true
var grabbed_player: Node = null var grabbed_player: Node = null
var random_move_dir: Vector2 = Vector2.ZERO var random_move_dir: Vector2 = Vector2.ZERO
var random_move_timer: float = 0.0 var random_move_timer: float = 0.0
@@ -18,6 +21,7 @@ const SNATCH_DAMAGE: float = 12.0
const GRAB_COOLDOWN: float = 2.0 # Cooldown after grabbing before can grab again const GRAB_COOLDOWN: float = 2.0 # Cooldown after grabbing before can grab again
const TILE_SIZE: int = 16 const TILE_SIZE: int = 16
const TILE_STRIDE: int = 17 # 16 + separation 1 const TILE_STRIDE: int = 17 # 16 + separation 1
@onready var detection_area: Area2D = $DetectionArea
@onready var emerge_area: Area2D = $EmergeArea @onready var emerge_area: Area2D = $EmergeArea
@onready var grab_area: Area2D = $GrabPlayerArea @onready var grab_area: Area2D = $GrabPlayerArea
@onready var interest_area: Area2D = $PlayerInterestArea @onready var interest_area: Area2D = $PlayerInterestArea
@@ -42,7 +46,7 @@ func _ready() -> void:
# Start hidden # Start hidden
modulate.a = 0.0 modulate.a = 0.0
# Areas detect players (layer 1) # Areas detect players (layer 1)
for area in [emerge_area, grab_area, interest_area]: for area in [detection_area, emerge_area, grab_area, interest_area]:
if area: if area:
area.set_collision_mask_value(1, true) area.set_collision_mask_value(1, true)
area.monitoring = true area.monitoring = true
@@ -247,6 +251,59 @@ func _ai_behavior(delta: float) -> void:
move_and_slide() move_and_slide()
func _on_detection_area_body_entered(body: Node2D) -> void:
if not body.is_in_group("player"):
return
if state != HandState.HIDDEN:
return
if is_detected:
return
var peer_id = body.get_multiplayer_authority()
if player_detection_attempts.has(peer_id):
return
player_detection_attempts[peer_id] = true
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer():
_roll_perception_check_hand(body)
func _roll_perception_check_hand(player: Node) -> void:
if not player or not player.character_stats:
return
var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per")
var roll = randi() % 20 + 1
var total = roll + int(per_stat / 2) - 5
var dc = 15
if total >= dc:
_detect_hand(player)
else:
pass # Remain hidden to this player
func _detect_hand(detecting_player: Node) -> void:
is_detected = true
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("spawn_detected_effect_at"):
game_world.spawn_detected_effect_at(global_position, name, "enemy")
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"):
detecting_player._on_trap_detected()
# Effect is synced to clients via game_world.spawn_detected_effect_at -> _sync_spawn_detected_effect
func _remove_detected_effect() -> void:
# Effect is under Entities (not our child) so it stays visible while we're hidden; remove by position and sync
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("remove_detected_effect_at_position"):
game_world.remove_detected_effect_at_position(global_position)
if multiplayer.has_multiplayer_peer():
game_world._sync_remove_detected_effect_at_position.rpc(global_position.x, global_position.y)
return
# Fallback: effect might be our child (legacy)
for c in get_children():
if c.name == "DetectedEffect":
c.queue_free()
return
func _on_emerge_area_body_entered(body: Node2D) -> void: func _on_emerge_area_body_entered(body: Node2D) -> void:
if state != HandState.HIDDEN: if state != HandState.HIDDEN:
return return
@@ -257,6 +314,8 @@ func _on_emerge_area_body_entered(body: Node2D) -> void:
if not is_multiplayer_authority(): if not is_multiplayer_authority():
return return
# Reveal effect is removed only when emerge animation finishes (_on_animation_finished)
state = HandState.EMERGING state = HandState.EMERGING
modulate.a = 1.0 modulate.a = 1.0
if anim_player and anim_player.has_animation("emerge"): if anim_player and anim_player.has_animation("emerge"):
@@ -272,6 +331,7 @@ func _on_emerge_area_body_entered(body: Node2D) -> void:
func _on_animation_finished(anim_name: StringName) -> void: func _on_animation_finished(anim_name: StringName) -> void:
if anim_name == "emerge": if anim_name == "emerge":
_remove_detected_effect() # Remove reveal effect only once hand has fully emerged
state = HandState.IDLE state = HandState.IDLE
if anim_player and anim_player.has_animation("idle"): if anim_player and anim_player.has_animation("idle"):
anim_player.play("idle") anim_player.play("idle")
@@ -330,7 +390,7 @@ func _sync_hand_emerged():
# Sync hand emergence visibility to clients # Sync hand emergence visibility to clients
if is_multiplayer_authority(): if is_multiplayer_authority():
return # Authority already handled it locally return # Authority already handled it locally
# Effect removed when emerge animation finishes on each client (_on_animation_finished)
if state == HandState.HIDDEN: if state == HandState.HIDDEN:
state = HandState.EMERGING state = HandState.EMERGING
modulate.a = 1.0 modulate.a = 1.0

View File

@@ -60,7 +60,8 @@ var shield_block_chance: float = 0.0
var is_blocking: bool = false # Whether enemy is actively blocking with shield var is_blocking: bool = false # Whether enemy is actively blocking with shield
var shield_block_speed_multiplier: float = 0.5 # Movement speed multiplier when blocking var shield_block_speed_multiplier: float = 0.5 # Movement speed multiplier when blocking
var shield_block_timer: float = 0.0 # Timer for how long to keep blocking var shield_block_timer: float = 0.0 # Timer for how long to keep blocking
var shield_block_duration: float = 1.5 # How long to block after raising shield var shield_block_duration: float = 1.0 # How long to block after raising shield (shorter so they attack more)
const SHIELD_BLOCK_MIN_HOLD: float = 0.35 # Minimum time to keep shield up (shorter so they can attack sooner)
var can_lift_throw: bool = false var can_lift_throw: bool = false
var spell_cooldown_timer: float = 0.0 var spell_cooldown_timer: float = 0.0
var bomb_cooldown_timer: float = 0.0 var bomb_cooldown_timer: float = 0.0
@@ -766,7 +767,7 @@ func _load_random_headgear():
"SoldierIronHelmBlue.png", "SoldierSteelHelmBlue.png" "SoldierIronHelmBlue.png", "SoldierSteelHelmBlue.png"
], ],
"Basic Range": [ "Basic Range": [
"ArcherHatCyan.png", "HunterHatRed.png", "RangerHatGreen.png", "RogueHatGreen.png" "ArcherHatCyan.png", "HunterHatRed.png", "RogueHatGreen.png"
], ],
"French": ["MusketeerHatPurple.png"], "French": ["MusketeerHatPurple.png"],
"Japanese": [ "Japanese": [
@@ -978,10 +979,10 @@ func _assign_loadout():
elif spell_roll < 0.20: elif spell_roll < 0.20:
spell_type = "healing" spell_type = "healing"
LogManager.log(str(name) + " assigned healing spell (tome spell enemy)", LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " assigned healing spell (tome spell enemy)", LogManager.CATEGORY_ENEMY)
# Shield: ~24% get shield, block chance 0.120.22 # Shield: ~24% get shield, block chance 0.060.12 (reduced so they attack more)
if appearance_rng.randf() < 0.24: if appearance_rng.randf() < 0.24:
has_shield = true has_shield = true
shield_block_chance = 0.12 + appearance_rng.randf() * 0.10 shield_block_chance = 0.06 + appearance_rng.randf() * 0.06
# Lift/throw: ~12% can grab and throw liftable objects when aggro # Lift/throw: ~12% can grab and throw liftable objects when aggro
if appearance_rng.randf() < 0.12: if appearance_rng.randf() < 0.12:
can_lift_throw = true can_lift_throw = true
@@ -1016,32 +1017,10 @@ func _get_nearby_liftable() -> Node:
return best return best
func _physics_process(delta): func _physics_process(delta):
# Always update animation (even when dead, for death animation) # Always update animation (even when dead or in fallout, for correct visuals)
_update_animation(delta) _update_animation(delta)
# Handle dead state (from parent) # Humanoid-specific timers (base updates attack_timer; run before base AI)
if is_dead:
if is_knocked_back:
knockback_time += delta
if knockback_time >= knockback_duration:
is_knocked_back = false
knockback_time = 0.0
velocity = Vector2.ZERO
else:
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
move_and_slide()
return
# Only server (authority) runs AI and physics
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
return
# Update attack timer and reset attack flags when cooldown is over
if attack_timer > 0:
attack_timer -= delta
if attack_timer <= 0:
can_attack = true
is_attacking = false
if spell_cooldown_timer > 0: if spell_cooldown_timer > 0:
spell_cooldown_timer -= delta spell_cooldown_timer -= delta
if bomb_cooldown_timer > 0: if bomb_cooldown_timer > 0:
@@ -1055,47 +1034,28 @@ func _physics_process(delta):
if shield_block_timer <= 0: if shield_block_timer <= 0:
is_blocking = false is_blocking = false
_update_shield_visibility() _update_shield_visibility()
# Update bow charge pulse timer when charging bow
if ai_state == AIState.BOW_CHARGING and is_charging_attack: if ai_state == AIState.BOW_CHARGING and is_charging_attack:
bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged
_apply_bow_charge_tint() _apply_bow_charge_tint()
elif ai_state != AIState.BOW_CHARGING: elif ai_state != AIState.BOW_CHARGING:
# Clear bow charge tint when not charging
_clear_bow_charge_tint() _clear_bow_charge_tint()
# Handle knockback # Base handles: dead, not authority, fallout (drag/sink), knockback, attack_timer, _ai_behavior, avoidance, move_and_slide, collisions, burn
if is_knocked_back: super._physics_process(delta)
knockback_time += delta
if knockback_time >= knockback_duration:
is_knocked_back = false
knockback_time = 0.0
velocity = Vector2.ZERO
else:
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
# Enemy AI - only if not knocked back # Reset attack flags when cooldown expired (base decrements attack_timer only)
if not is_knocked_back: if attack_timer <= 0:
_ai_behavior(delta) can_attack = true
is_attacking = false
# Move # Sync position and animation to clients (only server sends; humanoid uses game_world RPC)
move_and_slide()
# Don't use contact-based attack for humanoids (they use sword projectiles)
# _check_player_collision() # Disabled - humanoids attack with sword projectiles instead
# Sync position and animation to clients (only server sends)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
# Use game_world to send RPC instead of rpc() on node instance
# This avoids node path resolution issues when clients haven't spawned yet
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 and game_world.has_method("_sync_enemy_position"): if game_world and game_world.has_method("_sync_enemy_position"):
# Send via game_world using enemy name/index and position for identification
var enemy_name = name var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1]) game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1])
else: else:
# Fallback: try direct call to _sync_position (not RPC)
_sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame) _sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame)
func _ai_behavior(delta): func _ai_behavior(delta):
@@ -1249,28 +1209,19 @@ func _chasing_behavior(delta_arg):
var to_player = (target_player.global_position - global_position).normalized() var to_player = (target_player.global_position - global_position).normalized()
# --- Shield blocking: raise shield when player is close and attacking --- # --- Shield blocking: only when player is actually attacking; shorter duration so we attack sometimes ---
if has_shield and shield_block_chance > 0: if has_shield and shield_block_chance > 0:
# Check if player is attacking (recently attacked or in melee range) # Only consider blocking when player is actually attacking (sword up), not just "close"
var player_is_attacking = false var player_is_attacking = (dist < 70.0 and "is_attacking" in target_player and target_player.is_attacking)
if dist < 60.0: # Close enough that player might attack # Raise shield only when player is attacking (not when merely close)
# Check if player is facing us and might be attacking if player_is_attacking and not is_blocking and randf() < 0.65:
if "is_attacking" in target_player and target_player.is_attacking: # 65% chance to block when player is attacking (not 100%), so we attack sometimes
player_is_attacking = true
# Also raise shield if player is very close (within attack range)
if dist < 50.0:
player_is_attacking = true
# Raise shield if player is attacking or very close
if player_is_attacking and not is_blocking:
is_blocking = true is_blocking = true
shield_block_timer = shield_block_duration shield_block_timer = shield_block_duration * 0.5 # Shorter: 0.75s so we lower and attack sooner
_update_shield_visibility() _update_shield_visibility()
# Play shield activation sound
if sfx_activate_shield: if sfx_activate_shield:
sfx_activate_shield.play() sfx_activate_shield.play()
elif not player_is_attacking and is_blocking and shield_block_timer <= 0: elif not player_is_attacking and is_blocking and shield_block_timer <= 0:
# Lower shield if player is not attacking and timer expired
is_blocking = false is_blocking = false
_update_shield_visibility() _update_shield_visibility()
@@ -1320,6 +1271,9 @@ func _chasing_behavior(delta_arg):
# --- Melee: close enough to attack --- # --- Melee: close enough to attack ---
if dist < 45.0 and can_attack and not is_charging_attack: if dist < 45.0 and can_attack and not is_charging_attack:
# When blocking, still allow attack 40% of the time so we don't only block forever
if is_blocking and shield_block_timer > 0 and randf() >= 0.40:
return
# Lower shield when attacking (can't block while attacking) # Lower shield when attacking (can't block while attacking)
if is_blocking: if is_blocking:
is_blocking = false is_blocking = false
@@ -2183,27 +2137,41 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals
if has_shield and shield_block_chance > 0 and from_position != Vector2.ZERO and not is_burn_damage: if has_shield and shield_block_chance > 0 and from_position != Vector2.ZERO and not is_burn_damage:
var to_attacker = (from_position - global_position).normalized() var to_attacker = (from_position - global_position).normalized()
var facing = _get_attack_direction_vector() var facing = _get_attack_direction_vector()
# Check if attack is coming from the direction we're facing (blocking direction) var attack_from_front = to_attacker.dot(facing) > 0.5
if to_attacker.dot(facing) > 0.5 and randf() < shield_block_chance: # Full block when already blocking and attack from front, or reactive block roll
# Successfully blocked - reduce damage var did_block = (is_blocking and attack_from_front) or (attack_from_front and randf() < shield_block_chance)
amount = amount * 0.5 if did_block:
# Raise shield if not already blocking # Full block: no damage, show BLOCKED to attacker, small knockback on attacker, chance to lower our shield
if not is_blocking: if not is_blocking:
is_blocking = true is_blocking = true
shield_block_timer = shield_block_duration shield_block_timer = max(shield_block_duration, SHIELD_BLOCK_MIN_HOLD)
_update_shield_visibility() _update_shield_visibility()
if sfx_activate_shield: if sfx_activate_shield:
sfx_activate_shield.play() sfx_activate_shield.play()
# Play block sound else:
shield_block_timer = max(shield_block_timer, SHIELD_BLOCK_MIN_HOLD)
if sfx_block_with_shield: if sfx_block_with_shield:
sfx_block_with_shield.play() sfx_block_with_shield.play()
# Face the attacker
current_direction = _get_direction_from_vector(to_attacker) as Direction current_direction = _get_direction_from_vector(to_attacker) as Direction
# Notify attacker (player) to show BLOCKED and apply small knockback
var attacker = _find_nearest_player_to_position(from_position)
if attacker and is_instance_valid(attacker) and attacker.has_method("_on_attack_blocked_by_enemy"):
var pid = attacker.get_multiplayer_authority()
if multiplayer.get_unique_id() == pid:
attacker._on_attack_blocked_by_enemy(global_position)
elif multiplayer.has_multiplayer_peer() and is_inside_tree() and pid != 0:
attacker._on_attack_blocked_by_enemy.rpc_id(pid, global_position)
# Chance to lower shield after blocking so they can attack (35%)
if randf() < 0.35:
is_blocking = false
shield_block_timer = 0.0
_update_shield_visibility()
return
else: else:
# Attack not blocked, but raise shield anyway if we have one (defensive reaction) # Attack not blocked; brief defensive raise (shorter so they attack again sooner)
if not is_blocking: if not is_blocking and randf() < 0.5:
is_blocking = true is_blocking = true
shield_block_timer = shield_block_duration * 0.8 # Shorter duration for reactive blocking shield_block_timer = max(shield_block_duration * 0.4, SHIELD_BLOCK_MIN_HOLD)
_update_shield_visibility() _update_shield_visibility()
if sfx_activate_shield: if sfx_activate_shield:
sfx_activate_shield.play() sfx_activate_shield.play()

170
src/scripts/enemy_spider.gd Normal file
View File

@@ -0,0 +1,170 @@
extends "res://scripts/enemy_base.gd"
# Spider: 8 directions from spider.png (3h x 3v). Slow, walks toward player.
# When close: vibrate, then lunge at player and deal damage on contact.
# Spritesheet frame order: 0=down, 1=downright, 2=right, 3=upright, 4=up, 5=upleft, 6=left, 7=downleft
const MOVE_SPEED_SPIDER: float = 28.0
const ATTACK_RANGE: float = 24.0
const VIBRATE_DURATION: float = 0.35
const ATTACK_DAMAGE: float = 8.0
const LUNGE_SPEED: float = 140.0
const LUNGE_DURATION: float = 0.22
var vibrate_timer: float = 0.0
var lunge_timer: float = 0.0
var direction_frame: int = 0 # 0-7 for 8 directions
@onready var spider_sprite: Sprite2D = $Sprite2D
@onready var shadow_sprite: Sprite2D = $Shadow
func _ready() -> void:
super._ready()
exp_reward = 1.0 # Spiders give only 1 EXP and no loot
# Boss-spawned spiders (dungeon_spawned) have less HP so they go down faster
if get_meta("dungeon_spawned", false):
max_health = 12.0
var base_max = (character_stats.baseStats.end + character_stats.get_pass("end")) * 3.0 + character_stats.get_pass("maxhp")
character_stats.bonusmaxhp = max_health - base_max
character_stats.hp = character_stats.maxhp
current_health = character_stats.hp
else:
max_health = 22.0
current_health = max_health
move_speed = MOVE_SPEED_SPIDER
damage = ATTACK_DAMAGE
attack_cooldown = 0.6
if spider_sprite:
spider_sprite.hframes = 3
spider_sprite.vframes = 3
if shadow_sprite:
shadow_sprite.hframes = 3
shadow_sprite.vframes = 3
shadow_sprite.frame = direction_frame
func take_damage(amount: float, from_position: Vector2, is_critical: bool = false, is_burn_damage: bool = false, apply_burn_debuff: bool = false) -> void:
super.take_damage(amount, from_position, is_critical, is_burn_damage, apply_burn_debuff)
# Reset attack state so spider doesn't keep lunging/vibrating through the hit
_reset_attack_behavior()
func _reset_attack_behavior() -> void:
vibrate_timer = 0.0
lunge_timer = 0.0
velocity = Vector2.ZERO
if spider_sprite:
spider_sprite.position = Vector2.ZERO
if shadow_sprite:
shadow_sprite.position = Vector2.ZERO
func _physics_process(delta: float) -> void:
if is_dead:
move_and_slide()
return
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
move_and_slide()
return
# Spiders can fall into fallout like rat/slime (base logic is in _check_and_handle_fallout)
if _check_and_handle_fallout(delta):
return
if attack_timer > 0:
attack_timer -= delta
target_player = _find_nearest_player()
if target_player and is_instance_valid(target_player):
var to_player = target_player.global_position - global_position
var dist = to_player.length()
var dir = to_player.normalized()
if lunge_timer > 0:
# In lunge: keep velocity toward player; damage is applied on contact via _check_player_collision below
lunge_timer -= delta
if lunge_timer <= 0:
velocity = Vector2.ZERO
# If we didn't hit during lunge but are still in range, deal damage (pounce "lands")
if dist <= ATTACK_RANGE * 1.5 and attack_timer <= 0:
_attack_player(target_player)
elif dist <= ATTACK_RANGE:
if attack_timer > 0:
velocity = Vector2.ZERO
else:
# Vibrate then lunge
velocity = Vector2.ZERO
vibrate_timer += delta
var vibrate_offset = Vector2(randf_range(-1.5, 1.5), randf_range(-1.5, 1.5)) if vibrate_timer < VIBRATE_DURATION else Vector2.ZERO
if spider_sprite:
spider_sprite.position = vibrate_offset
if shadow_sprite:
shadow_sprite.position = vibrate_offset
if vibrate_timer >= VIBRATE_DURATION:
vibrate_timer = 0.0
if spider_sprite:
spider_sprite.position = Vector2.ZERO
if shadow_sprite:
shadow_sprite.position = Vector2.ZERO
# Start lunge: throw ourselves at the player
_set_direction_from_vector(dir)
velocity = dir * LUNGE_SPEED
lunge_timer = LUNGE_DURATION
else:
if attack_timer > 0:
velocity = Vector2.ZERO
else:
velocity = dir * move_speed
_set_direction_from_vector(dir)
else:
velocity = velocity.lerp(Vector2.ZERO, delta * 4.0)
lunge_timer = 0.0
# Avoid stepping onto fallout (like base does for other ground enemies)
if not fallout_state and not fallout_defeat_started and not is_knocked_back 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_and_slide()
# Deal damage on contact (during lunge or normal movement); end lunge when we hit
if get_slide_collision_count() > 0:
for i in get_slide_collision_count():
var collider = get_slide_collision(i).get_collider()
if collider and collider.is_in_group("player"):
_attack_player(collider)
if lunge_timer > 0:
lunge_timer = 0.0
velocity = Vector2.ZERO
break
# Spider overrides _physics_process and does not call super, so base never sends position sync. Send it here so joiner sees movement.
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
_send_position_sync_to_clients()
func _set_direction_from_vector(dir: Vector2) -> void:
# Angle sector: 0=right, 1=downright, 2=down, 3=downleft, 4=left, 5=upleft, 6=up, 7=upright
var angle = dir.angle()
var sector = int(round(angle / (TAU / 8.0))) % 8
if sector < 0:
sector += 8
# Map to spritesheet order: down, downright, right, upright, up, upleft, left, downleft
const SHEET_FRAME: Array = [2, 1, 0, 7, 6, 5, 4, 3] # sector -> frame
direction_frame = SHEET_FRAME[sector]
anim_frame = direction_frame # keep in sync for network position sync
if spider_sprite:
spider_sprite.frame = direction_frame
if shadow_sprite:
shadow_sprite.frame = direction_frame
func _spawn_loot() -> void:
# Spiders do not drop loot
return
func _update_client_visuals() -> void:
super._update_client_visuals()
# Apply synced frame so client spider faces the right direction
if spider_sprite:
spider_sprite.frame = anim_frame
if shadow_sprite:
shadow_sprite.frame = anim_frame
direction_frame = anim_frame

View File

@@ -0,0 +1 @@
uid://7e0vssvq87hn

View File

@@ -15,6 +15,9 @@ var screenshake_timer: float = 0.0
var screenshake_duration: float = 0.0 var screenshake_duration: float = 0.0
var screenshake_strength: float = 0.0 var screenshake_strength: float = 0.0
# Boss intro: camera lerps to boss, then scream effect, then back to player
var camera_override_target: Vector2 = Vector2.ZERO
var local_players = [] var local_players = []
const BASE_CAMERA_ZOOM: float = 4.0 const BASE_CAMERA_ZOOM: float = 4.0
const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices
@@ -78,7 +81,10 @@ var _torch_darken_last_room_id: String = ""
var _torch_darken_target_scale: float = 1.0 var _torch_darken_target_scale: float = 1.0
var _torch_darken_current_scale: float = 1.0 var _torch_darken_current_scale: float = 1.0
const _TORCH_DARKEN_LERP_SPEED: float = 4.0 const _TORCH_DARKEN_LERP_SPEED: float = 4.0
const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through const _TORCH_DARKEN_MIN_SCALE: float = 0.52 # Floor brightness so it's never insanely dark; same for all players
var _synced_darkness_scale: float = 1.0 # Server syncs this to clients so host and joiner see same darkness
var _last_synced_darkness_sent: float = -1.0 # Server: last value we sent
var _darkness_sync_timer: float = 0.0 # Server: throttle sync RPCs
var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen) var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen)
var combined_seen: PackedInt32Array = PackedInt32Array() var combined_seen: PackedInt32Array = PackedInt32Array()
var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored
@@ -120,6 +126,10 @@ var last_safe_position_by_player: Dictionary = {} # player node path or name ->
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile) 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_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile before it breaks
const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16) const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16)
# Cracked floor: normally invisible; once per game per tile a player can roll perception when close to reveal
const CRACKED_DETECTION_RADIUS: float = 99.0 # Same as trap detection (pixels)
var cracked_revealed_tiles: Dictionary = {} # "x,y" -> true
var cracked_detection_attempts: Dictionary = {} # "peer_id|x|y" -> true
# Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator # Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
const _FALLOUT_CENTER = Vector2i(10, 12) const _FALLOUT_CENTER = Vector2i(10, 12)
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11) const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11)
@@ -157,6 +167,9 @@ var broken_objects: Dictionary = {} # object_index -> true
# Track defeated enemies (enemy_index -> true) for syncing to new clients # Track defeated enemies (enemy_index -> true) for syncing to new clients
var defeated_enemies: Dictionary = {} # enemy_index -> true var defeated_enemies: Dictionary = {} # enemy_index -> true
# Next enemy index for dynamically spawned enemies (e.g. boss spiders) - server only, avoid clash with dungeon indices
var next_dynamic_enemy_index: int = 50000
# Track opened chests (object_index -> true) for syncing to new clients # Track opened chests (object_index -> true) for syncing to new clients
var opened_chests: Dictionary = {} # object_index -> true var opened_chests: Dictionary = {} # object_index -> true
@@ -182,6 +195,9 @@ var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, pla
func _ready(): func _ready():
# Add to group for easy access # Add to group for easy access
add_to_group("game_world") add_to_group("game_world")
# Apply any boss spider spawns that arrived before we were in tree (joiner fix)
if network_manager and network_manager.has_method("apply_pending_boss_spider_spawns"):
network_manager.apply_pending_boss_spider_spawns(self)
# Connect network signals # Connect network signals
if network_manager: if network_manager:
@@ -200,6 +216,19 @@ func _ready():
# Initialize mouse cursor system # Initialize mouse cursor system
_init_mouse_cursor() _init_mouse_cursor()
# Startup arg: -dungeonlevel=N or --dungeonlevel=N so host/single-player starts on that level
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer():
for arg in OS.get_cmdline_args():
var level_val: int = -1
if arg.begins_with("-dungeonlevel="):
level_val = int(arg.split("=")[1])
elif arg.begins_with("--dungeonlevel="):
level_val = int(arg.split("=")[1])
if level_val >= 1:
current_level = clampi(level_val, 1, 99)
LogManager.log("GameWorld: Starting on level " + str(current_level) + " (from -dungeonlevel=" + str(level_val) + ")", LogManager.CATEGORY_DUNGEON)
break
# Generate dungeon on host only # Generate dungeon on host only
# Only generate if we're the server (not just "no multiplayer peer") # Only generate if we're the server (not just "no multiplayer peer")
# This prevents clients from generating their own dungeon before connecting # This prevents clients from generating their own dungeon before connecting
@@ -472,6 +501,12 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
if is_inside_tree(): if is_inside_tree():
_push_existing_players_state_to_client(peer_id) _push_existing_players_state_to_client(peer_id)
) )
# Send current darkness scale so joiner sees same brightness as host immediately
get_tree().create_timer(0.25).timeout.connect(func():
if is_inside_tree() and multiplayer.is_server():
var dark_scale = maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
_sync_darkness_scale.rpc_id(peer_id, dark_scale)
)
# Sync broken interactable objects to the new client (immediate + delayed retry so joiner always gets it after objects exist) # Sync broken interactable objects to the new client (immediate + delayed retry so joiner always gets it after objects exist)
call_deferred("_sync_broken_objects_to_client", peer_id) call_deferred("_sync_broken_objects_to_client", peer_id)
@@ -483,6 +518,17 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
# 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
call_deferred("_sync_existing_enemies_to_client", peer_id) call_deferred("_sync_existing_enemies_to_client", peer_id)
# Sync existing boss-spawned spiders (so joiner sees them if they connected after spawn)
# Send immediately and again after delays so joiner 100% gets it (handles RPC loss or early processing)
call_deferred("_sync_existing_boss_spiders_to_client", peer_id)
get_tree().create_timer(1.5).timeout.connect(func():
if is_inside_tree() and multiplayer.is_server():
_sync_existing_boss_spiders_to_client(peer_id)
)
get_tree().create_timer(3.0).timeout.connect(func():
if is_inside_tree() and multiplayer.is_server():
_sync_existing_boss_spiders_to_client(peer_id)
)
# Sync existing chest open states to the new client # Sync existing chest open states to the new client
# Wait a bit after dungeon sync to ensure objects are spawned first # Wait a bit after dungeon sync to ensure objects are spawned first
@@ -818,6 +864,37 @@ func _sync_existing_enemies_to_client(client_peer_id: int):
_sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type) _sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type)
print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type) print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type)
func _sync_existing_boss_spiders_to_client(client_peer_id: int) -> void:
# Sync any living boss-spawned spiders to a new joiner (so they see spiders that were spawned before they connected)
if not is_inside_tree() or not multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var boss = null
for child in entities_node.get_children():
if child.has_meta("is_boss") or child.has_method("_sync_boss_position_to_clients"):
boss = child
break
if not boss or not "spawned_spiders" in boss:
return
var positions: Array = []
var indices: Array = []
for spider in boss.spawned_spiders:
if is_instance_valid(spider) and not ("is_dead" in spider and spider.is_dead):
positions.append(spider.global_position)
indices.append(spider.get_meta("enemy_index") if spider.has_meta("enemy_index") else -1)
if positions.is_empty():
return
var num_spiders = positions.size()
# Pad to 3 slots (use -1 index and 0,0 for empty so client skips them)
while positions.size() < 3:
positions.append(Vector2.ZERO)
indices.append(-1)
# Use NetworkManager relay so joiner's autoload receives RPC even if GameWorld path differs
network_manager.spawn_boss_spiders_client.rpc_id(client_peer_id, positions[0].x, positions[0].y, positions[1].x, positions[1].y, positions[2].x, positions[2].y, indices[0], indices[1], indices[2])
LogManager.log("GameWorld: Synced " + str(num_spiders) + " boss spiders to client " + str(client_peer_id), LogManager.CATEGORY_DUNGEON)
func _cleanup_disconnected_peers(): func _cleanup_disconnected_peers():
"""Periodically check and remove disconnected peers from client_gameworld_ready""" """Periodically check and remove disconnected peers from client_gameworld_ready"""
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
@@ -1034,6 +1111,88 @@ func _sync_enemy_spawn(spawner_name: String, spawn_position: Vector2, scene_inde
# Call spawn method on the spawner with scene index and humanoid_type # Call spawn method on the spawner with scene index and humanoid_type
spawner.spawn_enemy_at_position(spawn_position, scene_index, humanoid_type) spawner.spawn_enemy_at_position(spawn_position, scene_index, humanoid_type)
@rpc("authority", "reliable")
func _sync_boss_web_shot(boss_x: float, boss_y: float, t1x: float, t1y: float, t2x: float, t2y: float, t3x: float, t3y: float):
# Clients spawn 3 web shots at boss position with the 3 targets (visual only)
if multiplayer.is_server():
return
var web_shot_scene = load("res://scenes/attack_web_shot.tscn") as PackedScene
if not web_shot_scene:
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var boss_pos = Vector2(boss_x, boss_y)
var targets = [Vector2(t1x, t1y), Vector2(t2x, t2y), Vector2(t3x, t3y)]
for i in range(3):
var shot = web_shot_scene.instantiate()
shot.global_position = boss_pos
if shot.has_method("set_target"):
shot.set_target(targets[i])
entities_node.add_child(shot)
func request_spawn_boss_spiders(positions: Array) -> Array:
# Server only: spawn 3 spiders with unique enemy_index, then RPC to clients. Returns array of spawned spider nodes.
var spawned: Array = []
if not multiplayer.is_server() or positions.size() < 3:
return spawned
var entities_node = get_node_or_null("Entities")
if not entities_node:
return spawned
var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
if not spider_scene:
return spawned
var idx0 = next_dynamic_enemy_index
var idx1 = next_dynamic_enemy_index + 1
var idx2 = next_dynamic_enemy_index + 2
next_dynamic_enemy_index += 3
var pos0: Vector2 = positions[0] if positions[0] is Vector2 else Vector2(positions[0].x, positions[0].y)
var pos1: Vector2 = positions[1] if positions[1] is Vector2 else Vector2(positions[1].x, positions[1].y)
var pos2: Vector2 = positions[2] if positions[2] is Vector2 else Vector2(positions[2].x, positions[2].y)
for i in range(3):
var pos = [pos0, pos1, pos2][i]
var idx = [idx0, idx1, idx2][i]
var spider = spider_scene.instantiate()
spider.name = "Enemy_%d" % idx
spider.set_meta("enemy_index", idx)
spider.set_meta("dungeon_spawned", true)
if multiplayer.has_multiplayer_peer():
spider.set_multiplayer_authority(1)
spider.collision_mask = 1 | 2 | 64 # players, objects, walls
entities_node.add_child(spider)
spider.global_position = pos
spawned.append(spider)
# Send spawn via NetworkManager (autoload) so joiner always receives it regardless of GameWorld path
for peer_id in multiplayer.get_peers():
network_manager.spawn_boss_spiders_client.rpc_id(peer_id, pos0.x, pos0.y, pos1.x, pos1.y, pos2.x, pos2.y, idx0, idx1, idx2)
return spawned
# Called from NetworkManager.spawn_boss_spiders_client on clients (relay ensures joiner receives RPC)
func _do_client_spawn_boss_spiders(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void:
var entities_node = get_node_or_null("Entities")
if not entities_node:
# Joiner: Entities not ready yet; queue so we apply when dungeon is ready
if not multiplayer.is_server() and network_manager:
network_manager.pending_boss_spider_spawns.append({"p1x": p1x, "p1y": p1y, "p2x": p2x, "p2y": p2y, "p3x": p3x, "p3y": p3y, "idx0": idx0, "idx1": idx1, "idx2": idx2})
return
var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
if not spider_scene:
return
var positions = [Vector2(p1x, p1y), Vector2(p2x, p2y), Vector2(p3x, p3y)]
var indices = [idx0, idx1, idx2]
for i in range(3):
if indices[i] < 0:
continue # Skip slot when syncing 12 spiders to new joiner
var spider = spider_scene.instantiate()
spider.name = "Enemy_%d" % indices[i]
spider.set_meta("enemy_index", indices[i])
spider.set_meta("dungeon_spawned", true)
spider.collision_mask = 1 | 2 | 64 # players, objects, walls
if multiplayer.has_multiplayer_peer():
spider.set_multiplayer_authority(1)
entities_node.add_child(spider)
spider.global_position = positions[i]
# Loot ID counter (server only) # Loot ID counter (server only)
var loot_id_counter: int = 0 var loot_id_counter: int = 0
@@ -1285,6 +1444,14 @@ func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display
var heal_text = prefix + "+" + str(display_amount) + " HP" var heal_text = prefix + "+" + str(display_amount) + " HP"
_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) _show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0)
# Server-only: send enemy position to ALL peers (including joiner who may not be in client_gameworld_ready yet).
# Boss-spawned spiders and other enemies need position sync to reach joiners; _rpc_to_ready_peers can miss them.
func _broadcast_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int) -> void:
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
return
for peer_id in multiplayer.get_peers():
_sync_enemy_position.rpc_id(peer_id, enemy_name, enemy_index, pos, vel, z_pos, dir, frame, anim, frame_num, state_value)
@rpc("authority", "unreliable") @rpc("authority", "unreliable")
func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int): func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int):
# Clients receive enemy position updates from server # Clients receive enemy position updates from server
@@ -1345,6 +1512,30 @@ func _sync_enemy_death(enemy_name: String, enemy_index: int):
# This is okay, just log it # This is okay, just log it
print("GameWorld: Could not find enemy for death sync: name=", enemy_name, " index=", enemy_index) print("GameWorld: Could not find enemy for death sync: name=", enemy_name, " index=", enemy_index)
@rpc("authority", "reliable")
func _sync_boss_health(enemy_name: String, enemy_index: int, current_health: float, max_health: float) -> void:
# Clients update boss node's health so HUD boss bar shows correct value
if multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var enemy = null
for child in entities_node.get_children():
if child.is_in_group("enemy"):
if child.name == enemy_name:
enemy = child
break
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
enemy = child
break
if enemy:
enemy.current_health = current_health
enemy.max_health = max_health
if enemy.get("character_stats") and enemy.character_stats:
enemy.character_stats.hp = current_health
enemy.character_stats.maxhp = max_health
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false): func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false):
# Clients receive enemy damage visual sync from server # Clients receive enemy damage visual sync from server
@@ -1370,6 +1561,14 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index: elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
enemy = child enemy = child
break break
# Fallback: boss may have different name on client; find by is_boss if exactly one
if enemy == null and enemy_index >= 0:
var boss_candidates = []
for child in entities_node.get_children():
if child.is_in_group("enemy") and child.has_meta("is_boss") and child.get_meta("is_boss"):
boss_candidates.append(child)
if boss_candidates.size() == 1:
enemy = boss_candidates[0]
if enemy and enemy.has_method("_sync_damage_visual"): if enemy and enemy.has_method("_sync_damage_visual"):
# Call the enemy's _sync_damage_visual method directly (not via RPC) # Call the enemy's _sync_damage_visual method directly (not via RPC)
@@ -1377,6 +1576,7 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
else: else:
# Enemy not found - might already be freed or never spawned # Enemy not found - might already be freed or never spawned
# This is okay, just log it # This is okay, just log it
if enemy == null:
print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)
@rpc("authority", "reliable") @rpc("authority", "reliable")
@@ -1408,6 +1608,30 @@ func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool):
# Enemy not found - might already be freed or never spawned # Enemy not found - might already be freed or never spawned
print("GameWorld: Could not find enemy for burn debuff sync: name=", enemy_name, " index=", enemy_index) print("GameWorld: Could not find enemy for burn debuff sync: name=", enemy_name, " index=", enemy_index)
# Any peer can call; only server processes. Lets other player cut web when netted player's client doesn't have the web node.
@rpc("any_peer", "reliable")
func request_cut_web(attack_pos_x: float, attack_pos_y: float, radius: float, attacker_peer_id: int) -> void:
if not multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var attack_pos = Vector2(attack_pos_x, attack_pos_y)
for child in entities_node.get_children():
if not child is Area2D:
continue
if not child.has_method("cut_by_attack") or child.get("state") != "hit_player":
continue
var hit_player_node = child.get("hit_player")
if not is_instance_valid(hit_player_node) or not hit_player_node.is_in_group("player"):
continue
# Don't let netted player "cut" their own web
if hit_player_node.get_multiplayer_authority() == attacker_peer_id:
continue
if child.global_position.distance_to(attack_pos) <= radius:
child.cut_by_attack(null)
return
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2): func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2):
# Clients receive enemy attack sync from server # Clients receive enemy attack sync from server
@@ -1843,6 +2067,20 @@ func _sync_loot_remove(loot_id: int, loot_position: Vector2):
# Loot not found - might already be freed or never spawned # Loot not found - might already be freed or never spawned
print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position) print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position)
@rpc("authority", "reliable")
func _sync_key_respawn(loot_id: int, safe_position: Vector2):
# Clients: reposition key loot at safe tile after it sank into fallout (keys respawn like player)
if multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
for child in entities_node.get_children():
if child.is_in_group("loot") and child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id:
if child.has_method("_respawn_key_at_safe_position"):
child._respawn_key_at_safe_position(safe_position)
break
func _check_tab_visibility(): func _check_tab_visibility():
# Check if browser tab is visible (web only) # Check if browser tab is visible (web only)
if OS.get_name() == "Web": if OS.get_name() == "Web":
@@ -1872,6 +2110,10 @@ func _process(delta):
if use_mouse_control: if use_mouse_control:
_update_mouse_cursor(delta) _update_mouse_cursor(delta)
# Client: apply any pending boss spider spawns (in case RPC arrived when we weren't findable)
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
network_manager.apply_pending_boss_spider_spawns(self)
# Check tab visibility for buffer overflow protection (clients only) # Check tab visibility for buffer overflow protection (clients only)
if not multiplayer.is_server(): if not multiplayer.is_server():
var is_tab_visible = _check_tab_visibility() var is_tab_visible = _check_tab_visibility()
@@ -1932,6 +2174,8 @@ func _process(delta):
# Cracked floor: only server (or single-player) checks stand time and breaks tiles. # 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. # 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()): if dungeon_tilemap_layer_cracked and (multiplayer.is_server() or not multiplayer.has_multiplayer_peer()):
# Perception-based detection: when a player gets close to an unrevealed cracked tile, they roll once per game
_try_cracked_floor_detection()
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 []) 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: if players_to_check.is_empty() and player_manager:
players_to_check = player_manager.get_local_players() players_to_check = player_manager.get_local_players()
@@ -2100,14 +2344,18 @@ func _update_camera():
else: else:
screenshake_offset = Vector2.ZERO screenshake_offset = Vector2.ZERO
# Calculate center of all local players # Calculate center of all local players (or use override for boss intro)
var center = Vector2.ZERO var center = Vector2.ZERO
if camera_override_target != Vector2.ZERO:
center = camera_override_target
else:
for player in local_players: for player in local_players:
center += player.position center += player.position
center /= local_players.size() center /= local_players.size()
# Smooth camera movement (with screenshake) # Smooth camera movement (with screenshake); slower lerp when overriding (boss intro - gentle move to boss)
camera.position = camera.position.lerp(center + screenshake_offset, 0.1) var lerp_weight = 0.055 if camera_override_target != Vector2.ZERO else 0.1
camera.position = camera.position.lerp(center + screenshake_offset, lerp_weight)
# Base zoom with aspect ratio adjustment (show more on wider screens) # Base zoom with aspect ratio adjustment (show more on wider screens)
var viewport_size = get_viewport().get_visible_rect().size var viewport_size = get_viewport().get_visible_rect().size
@@ -2153,6 +2401,81 @@ func add_screenshake(strength: float, duration: float):
screenshake_duration = max(screenshake_duration, duration) # Use max duration screenshake_duration = max(screenshake_duration, duration) # Use max duration
screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger
func start_boss_intro_sequence(boss_node: Node) -> void:
# Only run on server / single player
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
return
if not is_instance_valid(boss_node):
return
var boss_pos = boss_node.global_position
# Joiner: run same camera + scream sequence locally (RPC so they see the pan too)
if multiplayer.has_multiplayer_peer():
_run_boss_intro_camera_client.rpc(boss_pos.x, boss_pos.y)
# Sequence: let player walk a couple of tiles into the room, then slowly lerp camera to boss, scream effect, return camera, start boss
var tween = create_tween()
tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
# Wait for player to be a couple of tiles into the boss room before moving camera
tween.tween_interval(1.5)
tween.tween_callback(func():
if is_instance_valid(boss_node):
camera_override_target = boss_node.global_position
else:
camera_override_target = boss_pos
)
# Slower camera lerp needs more time to reach boss (~2.8s to get most of the way there)
tween.tween_interval(2.8)
tween.tween_callback(_add_boss_scream_effect)
tween.tween_interval(1.2)
tween.tween_callback(func():
camera_override_target = Vector2.ZERO
)
# 0.5s camera back to player
tween.tween_interval(0.5)
tween.tween_callback(func():
if is_instance_valid(boss_node) and boss_node.has_method("_on_boss_intro_finished"):
boss_node._on_boss_intro_finished()
)
func _add_boss_scream_effect() -> void:
var layer = CanvasLayer.new()
layer.layer = 250
layer.name = "BossIntroEffect"
var rect = ColorRect.new()
rect.name = "ScreamRect"
rect.set_anchors_preset(Control.PRESET_FULL_RECT)
rect.offset_left = -1000
rect.offset_top = -1000
rect.offset_right = 2000
rect.offset_bottom = 2000
rect.color = Color(0.1, 0.0, 0.15, 0.0)
layer.add_child(rect)
add_child(layer)
var t = create_tween()
t.tween_property(rect, "color", Color(0.15, 0.0, 0.2, 0.65), 0.25).set_ease(Tween.EASE_OUT)
t.tween_interval(0.4)
t.tween_property(rect, "color", Color(0.1, 0.0, 0.15, 0.0), 0.35).set_ease(Tween.EASE_IN)
t.tween_callback(func(): layer.queue_free())
@rpc("authority", "reliable")
func _run_boss_intro_camera_client(boss_pos_x: float, boss_pos_y: float) -> void:
# Clients: run same camera pan + scream so joiner sees the intro (server runs full sequence and activates boss)
if multiplayer.is_server():
return
var boss_pos = Vector2(boss_pos_x, boss_pos_y)
var tween = create_tween()
tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
tween.tween_interval(1.5)
tween.tween_callback(func():
camera_override_target = boss_pos
)
tween.tween_interval(2.8)
tween.tween_callback(_add_boss_scream_effect)
tween.tween_interval(1.2)
tween.tween_callback(func():
camera_override_target = Vector2.ZERO
)
tween.tween_interval(0.5)
func _init_mouse_cursor(): func _init_mouse_cursor():
# Create cursor layer with high Z index # Create cursor layer with high Z index
cursor_layer = CanvasLayer.new() cursor_layer = CanvasLayer.new()
@@ -2568,6 +2891,88 @@ func _is_position_on_cracked_tile(world_pos: Vector2) -> bool:
return false return false
return td.get_custom_data("terrain") == -2 return td.get_custom_data("terrain") == -2
func _try_cracked_floor_detection() -> void:
# When a player gets close to an unrevealed cracked tile, they get one perception roll per tile per game (like traps)
if dungeon_data.is_empty() or not dungeon_data.has("cracked_tile_grid") or not dungeon_tilemap_layer_cracked:
return
var cracked_tile_grid = dungeon_data.cracked_tile_grid
var map_size: Vector2i = dungeon_data.map_size
var switch_tiles: Dictionary = {}
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:
switch_tiles[str(door_data.switch_tile_x) + "," + str(door_data.switch_tile_y)] = true
var players: Array = get_tree().get_nodes_in_group("player")
if players.is_empty() and player_manager:
players = player_manager.get_local_players()
for player in players:
if not is_instance_valid(player) or not player.is_in_group("player"):
continue
var pos: Vector2 = player.global_position
if player.has_node("QuicksandArea"):
var qa = player.get_node("QuicksandArea")
if is_instance_valid(qa):
pos = qa.global_position
var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
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 not cracked_tile_grid[x][y]:
continue
if switch_tiles.has(str(x) + "," + str(y)):
continue
var key_revealed = str(x) + "," + str(y)
if cracked_revealed_tiles.has(key_revealed):
continue
var tile_center: Vector2 = dungeon_tilemap_layer_cracked.map_to_local(Vector2i(x, y)) + dungeon_tilemap_layer_cracked.global_position
if pos.distance_to(tile_center) > CRACKED_DETECTION_RADIUS:
continue
var key_attempt = str(peer_id) + "|" + str(x) + "|" + str(y)
if cracked_detection_attempts.has(key_attempt):
continue
cracked_detection_attempts[key_attempt] = true
_roll_cracked_perception_check(player, x, y)
return # One roll per frame max
func _roll_cracked_perception_check(player: Node, tile_x: int, tile_y: int) -> void:
if not player or not player.character_stats:
return
var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per")
var roll = randi() % 20 + 1
var total = roll + int(per_stat / 2) - 5
var dc = 15
if total >= dc:
_reveal_cracked_tile(tile_x, tile_y, player)
else:
pass # Tile stays hidden for this attempt
func _reveal_cracked_tile(tile_x: int, tile_y: int, detecting_player: Node) -> void:
var key = str(tile_x) + "," + str(tile_y)
cracked_revealed_tiles[key] = true
if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.set_cell(Vector2i(tile_x, tile_y), 0, CRACKED_TILE_ATLAS)
var tile_center = dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position
spawn_detected_effect_at(tile_center, "", "trap")
if multiplayer.has_multiplayer_peer():
_sync_cracked_tile_revealed.rpc(tile_x, tile_y)
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_cracked_floor_detected"):
if detecting_player.is_multiplayer_authority():
detecting_player._on_cracked_floor_detected()
else:
detecting_player._on_cracked_floor_detected.rpc_id(detecting_player.get_multiplayer_authority())
@rpc("any_peer", "reliable", "call_remote")
func _sync_cracked_tile_revealed(tile_x: int, tile_y: int) -> void:
if multiplayer.is_server():
return
if not cracked_revealed_tiles.has(str(tile_x) + "," + str(tile_y)):
cracked_revealed_tiles[str(tile_x) + "," + str(tile_y)] = true
if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.set_cell(Vector2i(tile_x, tile_y), 0, CRACKED_TILE_ATLAS)
func _get_tile_coords_at_world(world_pos: Vector2) -> Vector2i: func _get_tile_coords_at_world(world_pos: Vector2) -> Vector2i:
if not dungeon_tilemap_layer: if not dungeon_tilemap_layer:
return Vector2i(-1, -1) return Vector2i(-1, -1)
@@ -2626,6 +3031,68 @@ func _play_whoosh_at(world_pos: Vector2) -> void:
player.play() player.play()
player.finished.connect(player.queue_free) player.finished.connect(player.queue_free)
func spawn_detected_effect_at(world_pos: Vector2, parent_node_name: String = "", effect_type: String = "chest") -> void:
# Spawn the "detected" effect at position; sync to all players.
# effect_type: "chest" (blue, 169-179), "trap" (purple, 274-284), "enemy" (red, 484-494).
# If parent_node_name is set (e.g. trap/enemy hand name), effect is added as child of that node so it can be removed when disarmed/emerged.
# For "enemy" type we always add to Entities so the effect stays visible (animating) while the hidden enemy has modulate.a = 0.
var scene = load("res://scenes/detected_effect.tscn") as PackedScene
if not scene:
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
entities_node = self
var parent = null
if parent_node_name and effect_type != "enemy":
parent = entities_node.get_node_or_null(parent_node_name)
var effect = scene.instantiate()
if parent:
parent.add_child(effect)
else:
entities_node.add_child(effect)
effect.global_position = world_pos
if effect.has_method("setup"):
effect.setup(world_pos, effect_type)
if multiplayer.has_multiplayer_peer():
_sync_spawn_detected_effect.rpc(world_pos.x, world_pos.y, parent_node_name, effect_type)
func remove_detected_effect_at_position(world_pos: Vector2) -> void:
# Remove a detected effect at/near this position (e.g. when cracked tile breaks and someone falls through)
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
const TOLERANCE = 20.0 # One tile + margin so we match the effect we spawned here
for c in entities_node.get_children():
if (c.name == "DetectedEffect" or c.name.begins_with("DetectedEffect")) and c.global_position.distance_to(world_pos) < TOLERANCE:
c.queue_free()
return
@rpc("any_peer", "reliable", "call_remote")
func _sync_remove_detected_effect_at_position(px: float, py: float) -> void:
remove_detected_effect_at_position(Vector2(px, py))
@rpc("any_peer", "reliable", "call_remote")
func _sync_spawn_detected_effect(px: float, py: float, parent_node_name: String = "", effect_type: String = "chest") -> void:
if multiplayer.is_server():
return
var scene = load("res://scenes/detected_effect.tscn") as PackedScene
if not scene:
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
entities_node = self
var parent = null
if parent_node_name and effect_type != "enemy":
parent = entities_node.get_node_or_null(parent_node_name)
var effect = scene.instantiate()
if parent:
parent.add_child(effect)
else:
entities_node.add_child(effect)
effect.global_position = Vector2(px, py)
if effect.has_method("setup"):
effect.setup(Vector2(px, py), effect_type)
func break_cracked_tiles_in_radius(world_center: Vector2, radius: float) -> void: 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. # 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. # Clients (e.g. joiner's bomb) request the server to do it via RPC.
@@ -2663,10 +3130,12 @@ func _break_cracked_tile(tile_x: int, tile_y: int) -> void:
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile) dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
if dungeon_tilemap_layer_decorated: if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
# Tile center for whoosh/effect removal: use same layer as spawn (cracked) so position matches
var tile_center = (dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position) if dungeon_tilemap_layer_cracked else (dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position)
if dungeon_tilemap_layer_cracked: if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y)) 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) _play_whoosh_at(tile_center)
remove_detected_effect_at_position(tile_center)
# Update dungeon_data so re-packed blob for joiners has correct floor (no separate broken list needed) # 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 multiplayer.is_server() and not dungeon_data.is_empty():
if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0: if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0:
@@ -2695,8 +3164,10 @@ func _sync_cracked_tile_broke(tile_x: int, tile_y: int, fallout_atlas_x: int, fa
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
if dungeon_tilemap_layer_cracked: if dungeon_tilemap_layer_cracked:
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y)) 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) # Match spawn position: use cracked layer if available, else main layer, else tile coords
var tile_center = (dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position) if dungeon_tilemap_layer_cracked else ((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) _play_whoosh_at(tile_center)
remove_detected_effect_at_position(tile_center)
func update_last_safe_position_for_player(player: Node, world_pos: Vector2) -> void: 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 # Only store when position is on a non-fallout tile so we never remember a pit as safe
@@ -2813,6 +3284,9 @@ func _init_fog_of_war():
_torch_darken_last_room_id = "" _torch_darken_last_room_id = ""
_torch_darken_target_scale = 1.0 _torch_darken_target_scale = 1.0
_torch_darken_current_scale = 1.0 _torch_darken_current_scale = 1.0
_synced_darkness_scale = 1.0
_last_synced_darkness_sent = -1.0
_darkness_sync_timer = 0.0
func _update_fog_of_war(delta: float) -> void: func _update_fog_of_war(delta: float) -> void:
if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"): if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
@@ -2986,7 +3460,14 @@ func _update_fog_of_war(delta: float) -> void:
break break
if exit_discovered: if exit_discovered:
break break
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered) var other_player_tiles: Array = []
var all_players = get_tree().get_nodes_in_group("player")
for p in all_players:
if not is_instance_valid(p) or p in local_player_list:
continue
var pt = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
other_player_tiles.append(pt)
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles)
func _create_seen_array(map_size: Vector2i) -> PackedInt32Array: func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
var size = map_size.x * map_size.y var size = map_size.x * map_size.y
@@ -3149,13 +3630,57 @@ func _median_torch_scale_from_rooms(rooms: Array) -> float:
median = clampf(median, 0.0, 4.0) median = clampf(median, 0.0, 4.0)
return median / 4.0 return median / 4.0
# Map raw torch scale (0..1) to display scale so we never go insanely dark (0.52..1.0)
func _torch_scale_to_display(raw: float) -> float:
return clampf(_TORCH_DARKEN_MIN_SCALE + (1.0 - _TORCH_DARKEN_MIN_SCALE) * raw, _TORCH_DARKEN_MIN_SCALE, 1.0)
# Compute target darkness scale for a given world position (for sync: server can use any player's position)
func _get_darkness_scale_at_position(world_pos: Vector2) -> float:
var p_tile = Vector2i(int(world_pos.x / FOG_TILE_SIZE), int(world_pos.y / FOG_TILE_SIZE))
var current_room = _find_room_at_tile(p_tile)
var in_room := not current_room.is_empty()
var raw: float
if in_room:
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
raw = tc / 4.0
else:
raw = _median_torch_scale_from_rooms(cached_corridor_rooms)
return _torch_scale_to_display(raw)
func _update_canvas_modulate_by_torches() -> void: func _update_canvas_modulate_by_torches() -> void:
if dungeon_data.is_empty() or not dungeon_data.has("torches"): if dungeon_data.is_empty() or not dungeon_data.has("torches"):
return return
var cm = get_node_or_null("CanvasModulate") var cm = get_node_or_null("CanvasModulate")
if not cm or not is_instance_valid(cm): if not cm or not is_instance_valid(cm):
return return
# Multiplayer: clients use server-synced scale so host and joiner see the same darkness
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
_synced_darkness_scale = clampf(_synced_darkness_scale, _TORCH_DARKEN_MIN_SCALE, 1.0)
var dt := get_process_delta_time()
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, _synced_darkness_scale, clampf(dt * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
var brightness := _torch_darken_current_scale
cm.color = Color(brightness, brightness, brightness)
return
# Server or single player: compute target scale
var local_list = player_manager.get_local_players() if player_manager else [] var local_list = player_manager.get_local_players() if player_manager else []
var target_scale: float
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
# Server: use brightest room any player is in so everyone gets same, reasonable darkness
var all_players = get_tree().get_nodes_in_group("player")
var max_scale: float = _TORCH_DARKEN_MIN_SCALE
for pl in all_players:
if is_instance_valid(pl) and "global_position" in pl:
var sc = _get_darkness_scale_at_position(pl.global_position)
if sc > max_scale:
max_scale = sc
target_scale = max_scale
# Throttled sync to clients so they match
_darkness_sync_timer += get_process_delta_time()
if _darkness_sync_timer >= 0.15 or abs(target_scale - _last_synced_darkness_sent) >= 0.03:
_darkness_sync_timer = 0.0
_last_synced_darkness_sent = target_scale
_sync_darkness_scale.rpc(target_scale)
else:
if local_list.is_empty() or not local_list[0]: if local_list.is_empty() or not local_list[0]:
return return
var p = local_list[0] var p = local_list[0]
@@ -3177,14 +3702,22 @@ func _update_canvas_modulate_by_torches() -> void:
_torch_darken_last_room_id = room_id _torch_darken_last_room_id = room_id
if in_room: if in_room:
var tc = clampi(_count_torches_in_room(current_room), 0, 4) var tc = clampi(_count_torches_in_room(current_room), 0, 4)
_torch_darken_target_scale = tc / 4.0 _torch_darken_target_scale = _torch_scale_to_display(tc / 4.0)
else: else:
_torch_darken_target_scale = _median_torch_scale_from_rooms(cached_corridor_rooms) _torch_darken_target_scale = _torch_scale_to_display(_median_torch_scale_from_rooms(cached_corridor_rooms))
target_scale = _torch_darken_target_scale
var delta := get_process_delta_time() var delta := get_process_delta_time()
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, _torch_darken_target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0)) if not (multiplayer.has_multiplayer_peer() and multiplayer.is_server()):
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
else:
_torch_darken_current_scale = target_scale
var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE) var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
cm.color = Color(s, s, s) cm.color = Color(s, s, s)
@rpc("authority", "reliable")
func _sync_darkness_scale(darkness_scale: float) -> void:
_synced_darkness_scale = clampf(darkness_scale, _TORCH_DARKEN_MIN_SCALE, 1.0)
func _reapply_torch_darkening() -> void: func _reapply_torch_darkening() -> void:
if dungeon_data.is_empty() or not dungeon_data.has("torches"): if dungeon_data.is_empty() or not dungeon_data.has("torches"):
return return
@@ -3198,15 +3731,15 @@ func _reapply_torch_darkening() -> void:
var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)) var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
var current_room = _find_room_at_tile(p_tile) var current_room = _find_room_at_tile(p_tile)
var in_room := not current_room.is_empty() var in_room := not current_room.is_empty()
var target: float var raw: float
if in_room: if in_room:
var tc = clampi(_count_torches_in_room(current_room), 0, 4) var tc = clampi(_count_torches_in_room(current_room), 0, 4)
target = tc / 4.0 raw = tc / 4.0
else: else:
target = _median_torch_scale_from_rooms(cached_corridor_rooms) raw = _median_torch_scale_from_rooms(cached_corridor_rooms)
var t := maxf(target, _TORCH_DARKEN_MIN_SCALE) var t := _torch_scale_to_display(raw)
_torch_darken_target_scale = target _torch_darken_target_scale = t
_torch_darken_current_scale = target _torch_darken_current_scale = t
var room_id := "" var room_id := ""
if in_room: if in_room:
room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
@@ -3407,9 +3940,12 @@ func _generate_dungeon():
if dungeon_seed == 0: if dungeon_seed == 0:
dungeon_seed = randi() dungeon_seed = randi()
# Create dungeon generator # Create dungeon generator — dungeon can be any size; we store the size once it's ready (in dungeon_data.map_size)
# Level 4 uses a larger map so the boss arena (first room) has plenty of space for the spider bat
var generator = load("res://scripts/dungeon_generator.gd").new() var generator = load("res://scripts/dungeon_generator.gd").new()
var map_size = Vector2i(72, 72) # 72x72 tiles var map_size = Vector2i(72, 72) # default 72x72 tiles
if current_level == 4:
map_size = Vector2i(96, 96) # bigger map for boss level so boss room is never cramped
# Hide all players and remove collision before generating new level # Hide all players and remove collision before generating new level
_hide_all_players() _hide_all_players()
@@ -3573,15 +4109,15 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
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.20, 0.30, 0.26), 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) — brightened so walls and purple floor are clearly visible
walls = [ walls = [
Color(20 / 255.0, 120 / 255.0, 40 / 255.0), Color(60 / 255.0, 180 / 255.0, 90 / 255.0), Color(120 / 255.0, 220 / 255.0, 160 / 255.0), Color(55 / 255.0, 160 / 255.0, 75 / 255.0), Color(95 / 255.0, 205 / 255.0, 125 / 255.0), Color(155 / 255.0, 238 / 255.0, 185 / 255.0),
Color(10 / 255.0, 60 / 255.0, 25 / 255.0), Color(180 / 255.0, 255 / 255.0, 210 / 255.0), Color(40 / 255.0, 90 / 255.0, 55 / 255.0), Color(35 / 255.0, 100 / 255.0, 50 / 255.0), Color(200 / 255.0, 255 / 255.0, 225 / 255.0), Color(75 / 255.0, 130 / 255.0, 85 / 255.0),
] ]
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.78, 0.52, 0.82), Color(0.82, 0.58, 0.86), Color(0.62, 0.42, 0.70),
Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38), Color(0.72, 0.48, 0.78), Color(0.68, 0.46, 0.74), Color(0.50, 0.42, 0.54),
Color(0.28, 0.22, 0.32), Color(0.24, 0.18, 0.28), Color(0.44, 0.38, 0.48), Color(0.40, 0.34, 0.44),
] ]
3: # 4⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility 3: # 4⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility
walls = [ walls = [
@@ -3880,25 +4416,8 @@ func _render_dungeon():
var dt = decorated_tile_grid[x][y] var dt = decorated_tile_grid[x][y]
if dt != null and dt is Vector2i: if dt != null and dt is Vector2i:
dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt) dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt)
if dungeon_data.has("cracked_tile_grid") and dungeon_tilemap_layer_cracked: # Cracked tiles: do NOT draw here; they start invisible and are revealed when a player detects them (perception roll)
var cracked_tile_grid = dungeon_data.cracked_tile_grid # cracked_revealed_tiles and set_cell are used in _try_cracked_floor_detection()
# 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)
@@ -5284,6 +5803,11 @@ func _reassemble_dungeon_blob():
_spawn_room_triggers() _spawn_room_triggers()
print("GameWorld: Client - Room triggers spawned") print("GameWorld: Client - Room triggers spawned")
# CRITICAL for joiner: apply any boss spider spawns that arrived before dungeon was ready
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
network_manager.apply_pending_boss_spider_spawns(self)
LogManager.log("GameWorld: Client applied pending boss spider spawns after dungeon blob", LogManager.CATEGORY_DUNGEON)
# Apply door states (from metadata) - after doors are spawned # Apply door states (from metadata) - after doors are spawned
if pending_door_states.size() > 0: if pending_door_states.size() > 0:
print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...") print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...")
@@ -5517,6 +6041,10 @@ func _sync_dungeon_entities(non_essential_data: Dictionary):
_spawn_blocking_doors() _spawn_blocking_doors()
_spawn_room_triggers() _spawn_room_triggers()
# Joiner: apply any boss spider spawns that arrived before we had Entities ready
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
network_manager.apply_pending_boss_spider_spawns(self)
func _fix_player_appearance_after_dungeon_sync(): func _fix_player_appearance_after_dungeon_sync():
# Re-randomize appearance for all players that were spawned before dungeon_seed was received # Re-randomize appearance for all players that were spawned before dungeon_seed was received
# This ensures all players (including joiners) have the same appearance across all clients # This ensures all players (including joiners) have the same appearance across all clients
@@ -5705,6 +6233,9 @@ func _spawn_enemies():
# 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)
# Boss flag (for HUD and behavior)
if enemy_data.get("is_boss", false):
enemy.set_meta("is_boss", true)
# Set position BEFORE add_child so humanoid _ready() sees correct global_position for unique appearance seed # Set position BEFORE add_child so humanoid _ready() sees correct global_position for unique appearance seed
enemy.position = enemy_data.position enemy.position = enemy_data.position
# Add to scene tree AFTER setting authority, stats, and position # Add to scene tree AFTER setting authority, stats, and position
@@ -5933,6 +6464,12 @@ func _spawn_interactable_objects():
else: else:
push_error("ERROR: Object does not have method: ", object_data.setup_function) push_error("ERROR: Object does not have method: ", object_data.setup_function)
# Hidden chest: make invisible until detected via perception
if object_data.get("hidden", false) and obj.object_type == "Chest":
obj.is_hidden = true
obj._apply_hidden_state()
LogManager.log("GameWorld: Spawned HIDDEN chest at " + str(object_data.position) + " (name: " + obj.name + ")", LogManager.CATEGORY_DUNGEON)
# Add to group for easy access # Add to group for easy access
obj.add_to_group("interactable_object") obj.add_to_group("interactable_object")
@@ -6302,6 +6839,17 @@ func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed:
_apply_trap_state_by_name(trap_name, is_detected, is_disarmed) _apply_trap_state_by_name(trap_name, is_detected, is_disarmed)
print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed) print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed)
@rpc("any_peer", "reliable", "call_remote")
func _sync_hidden_chest_detected_by_name(chest_name: String) -> void:
if multiplayer.is_server():
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var chest = entities_node.get_node_or_null(chest_name)
if chest and is_instance_valid(chest) and chest.has_method("_sync_hidden_chest_detected"):
chest._sync_hidden_chest_detected()
func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0): func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0):
# Sync broken interactable objects to new client with retry logic # Sync broken interactable objects to new client with retry logic
# Check if node is still valid and in tree # Check if node is still valid and in tree
@@ -8357,6 +8905,26 @@ func _sync_exp_text_at_player(amount: float, player_peer_id: int):
if player and is_instance_valid(player): if player and is_instance_valid(player):
_show_exp_number_at_player(amount, player) _show_exp_number_at_player(amount, player)
# Show damage number at world position (e.g. when attacker hits boss so they always see the number)
func show_damage_number_at_position(world_pos: Vector2, amount: float, is_critical: bool = false) -> void:
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var damage_label = damage_number_scene.instantiate()
if not damage_label:
return
damage_label.label = str(int(amount))
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35)
damage_label.z_index = 50
damage_label.direction = Vector2(0, -1)
var entities_node = get_node_or_null("Entities")
if entities_node:
entities_node.add_child(damage_label)
damage_label.global_position = world_pos + Vector2(0, -16)
else:
get_tree().current_scene.add_child(damage_label)
damage_label.global_position = world_pos + Vector2(0, -16)
func _show_exp_number_at_position(amount: float, exp_pos: Vector2): func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
# Show EXP number (green, using dmg_numbers.png font) at a specific position # Show EXP number (green, using dmg_numbers.png font) at a specific position
var damage_number_scene = preload("res://scenes/damage_number.tscn") var damage_number_scene = preload("res://scenes/damage_number.tscn")
@@ -9297,10 +9865,17 @@ func _spawn_blocking_doors():
door.requires_enemies = true door.requires_enemies = true
door.requires_switch = false door.requires_switch = false
LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON)
elif door_data.puzzle_type == "boss":
door.requires_enemies = true
door.requires_switch = false
door.set_meta("boss_room_door", true) # Check all enemies in room (pre-placed boss), not just spawner
LogManager.log("GameWorld: Door " + str(door.name) + " is boss room door - requires boss defeated", LogManager.CATEGORY_DUNGEON)
elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]: elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]:
door.requires_enemies = false door.requires_enemies = false
door.requires_switch = true door.requires_switch = true
LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON)
if door_data.get("boss_room_door", false):
door.set_meta("boss_room_door", true)
door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {} door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {}
door.switch_room = door_data.switch_room if "switch_room" in door_data else {} door.switch_room = door_data.switch_room if "switch_room" in door_data else {}
@@ -9626,7 +10201,12 @@ func _spawn_blocking_doors():
if "puzzle_type" in door_data: if "puzzle_type" in door_data:
LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON)
# CRITICAL: If door has no puzzle elements (neither switch nor spawner), this is an error # Boss room door: puzzle is the pre-placed boss (no switch or spawner)
if "puzzle_type" in door_data and door_data.puzzle_type == "boss":
has_puzzle_element = true
LogManager.log("GameWorld: Door " + str(door.name) + " is boss room door - puzzle element is pre-placed boss", LogManager.CATEGORY_DUNGEON)
# CRITICAL: If door has no puzzle elements (neither switch nor spawner nor boss), this is an error
# This should never happen if dungeon_generator logic is correct, but add safety check # This should never happen if dungeon_generator logic is correct, but add safety check
if door_data.type != "KeyDoor" and not has_puzzle_element: if door_data.type != "KeyDoor" and not has_puzzle_element:
push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!") push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!")
@@ -9836,6 +10416,18 @@ func _spawn_room_triggers():
LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON)
# Explicitly connect every blocking door to its room trigger (ensures boss room and all puzzle room doors close on enter)
_connect_all_doors_to_room_triggers()
func _connect_all_doors_to_room_triggers():
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
for child in entities_node.get_children():
if (child.is_in_group("blocking_door") or (child.name and child.name.begins_with("BlockingDoor_"))) and is_instance_valid(child):
if child.get("blocking_room") and not child.blocking_room.is_empty():
_connect_door_to_room_trigger(child)
func _place_key_in_room(room: Dictionary): func _place_key_in_room(room: Dictionary):
# Place a key in the specified room (as loot) # Place a key in the specified room (as loot)
# Only run on server - keys are not synced via RPC, so clients should not spawn them # Only run on server - keys are not synced via RPC, so clients should not spawn them
@@ -9857,14 +10449,16 @@ func _place_key_in_room(room: Dictionary):
var tile_size = 16 var tile_size = 16
var valid_positions = [] var valid_positions = []
# Room interior is from room.x + 2 to room.x + room.w - 2 # Room interior is from room.x + 2 to room.x + room.w - 2; exclude fallout tiles so the key doesn't sink
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 < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y: if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y:
if dungeon_data.grid[x][y] == 1: # Floor if dungeon_data.grid[x][y] == 1: # Floor
var world_x = x * tile_size + tile_size / 2.0 var world_x = x * tile_size + tile_size / 2.0
var world_y = y * tile_size + tile_size / 2.0 var world_y = y * tile_size + tile_size / 2.0
valid_positions.append(Vector2(world_x, world_y)) var world_pos = Vector2(world_x, world_y)
if not _is_position_on_fallout_tile(world_pos):
valid_positions.append(world_pos)
if valid_positions.size() > 0: if valid_positions.size() > 0:
# Use deterministic seed for key placement (ensures same position on host and clients) # Use deterministic seed for key placement (ensures same position on host and clients)
@@ -9911,8 +10505,8 @@ func _connect_door_to_room_trigger(door: Node):
if trigger_room and not trigger_room.is_empty() and \ if trigger_room and not trigger_room.is_empty() and \
trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \ trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \
trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h: trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h:
# Connect door to trigger # Connect door to trigger (avoid duplicate if room_trigger._find_room_entities already added it)
door.room_trigger_area = trigger door.room_trigger_area = trigger
# Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd) if door not in trigger.doors_in_room:
trigger.doors_in_room.append(door) trigger.doors_in_room.append(door)
break break

View File

@@ -16,6 +16,9 @@ var label_time: Label = null
var label_time_value: Label = null var label_time_value: Label = null
var label_boss: Label = null var label_boss: Label = null
var texture_progress_bar_boss_hp: TextureProgressBar = null var texture_progress_bar_boss_hp: TextureProgressBar = null
var center_bottom_boss: Control = null
var label_boss_center: Label = null
var progress_bar_boss_hp_center: ProgressBar = null
var label_host: Label = null var label_host: Label = null
var label_player_count: Label = null var label_player_count: Label = null
var label_room_code: Label = null var label_room_code: Label = null
@@ -46,6 +49,22 @@ func _ready():
label_time_value = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerTime/LabelTimeValue") label_time_value = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerTime/LabelTimeValue")
label_boss = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/LabelBoss") label_boss = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/LabelBoss")
texture_progress_bar_boss_hp = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/TextureProgressBarBossHP") texture_progress_bar_boss_hp = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/TextureProgressBarBossHP")
center_bottom_boss = get_node_or_null("CenterBottom")
label_boss_center = get_node_or_null("CenterBottom/VBoxBoss/LabelBossCenter")
progress_bar_boss_hp_center = get_node_or_null("CenterBottom/VBoxBoss/ProgressBarBossHP")
if progress_bar_boss_hp_center:
var bg = StyleBoxFlat.new()
bg.bg_color = Color(0.2, 0.2, 0.2, 0.9)
bg.set_border_width_all(2)
bg.border_color = Color(0.5, 0.2, 0.2)
progress_bar_boss_hp_center.add_theme_stylebox_override("background", bg)
var fill = StyleBoxFlat.new()
fill.bg_color = Color(0.9, 0.2, 0.2)
progress_bar_boss_hp_center.add_theme_stylebox_override("fill", fill)
if label_boss_center and ResourceLoader.exists("res://assets/fonts/standard_font.png"):
var fr = load("res://assets/fonts/standard_font.png")
if fr:
label_boss_center.add_theme_font_override("font", fr)
label_host = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelHost") label_host = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelHost")
label_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount") label_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount")
label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode") label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode")
@@ -86,11 +105,13 @@ func _ready():
if not game_world: if not game_world:
print("IngameHUD: WARNING - game_world not found in group") print("IngameHUD: WARNING - game_world not found in group")
# Initially hide boss health bar # Initially hide boss health bar (upper right and center bottom)
if texture_progress_bar_boss_hp: if texture_progress_bar_boss_hp:
texture_progress_bar_boss_hp.visible = false texture_progress_bar_boss_hp.visible = false
if label_boss: if label_boss:
label_boss.visible = false label_boss.visible = false
if center_bottom_boss:
center_bottom_boss.visible = false
# Update host info display # Update host info display
_update_host_info() _update_host_info()
@@ -387,32 +408,52 @@ func start_timer():
timer_running = true timer_running = true
level_start_time = Time.get_ticks_msec() / 1000.0 level_start_time = Time.get_ticks_msec() / 1000.0
var _boss_bar_fill_animating: bool = false
var _boss_bar_fill_tween_started: bool = false
func _update_boss_health(): func _update_boss_health():
# Find boss enemy (if any) # Find boss enemy that is ACTIVATED (bar only shows when boss is activated)
var boss_enemy = null var boss_enemy = null
var enemies = get_tree().get_nodes_in_group("enemy") var enemies = get_tree().get_nodes_in_group("enemy")
for enemy in enemies: for enemy in enemies:
# Check if enemy is a boss (could check metadata or name) if enemy.has_meta("is_boss") and enemy.get_meta("is_boss") and enemy.has_meta("boss_activated") and enemy.get_meta("boss_activated"):
if enemy.has_meta("is_boss") and enemy.get_meta("is_boss"):
boss_enemy = enemy boss_enemy = enemy
break break
if boss_enemy and is_instance_valid(boss_enemy): if boss_enemy and is_instance_valid(boss_enemy):
# Show boss health bar if center_bottom_boss and not center_bottom_boss.visible:
# First time showing: animate bar from 0 to full
if progress_bar_boss_hp_center:
progress_bar_boss_hp_center.value = 0.0
_boss_bar_fill_animating = true
if center_bottom_boss:
center_bottom_boss.visible = true
if texture_progress_bar_boss_hp: if texture_progress_bar_boss_hp:
texture_progress_bar_boss_hp.visible = true texture_progress_bar_boss_hp.visible = false
if label_boss: if label_boss:
label_boss.visible = true label_boss.visible = false
# Update boss health (properties are always defined in enemy_base.gd)
var health = boss_enemy.current_health var health = boss_enemy.current_health
var max_health = boss_enemy.max_health var max_health = max(1.0, boss_enemy.max_health)
if progress_bar_boss_hp_center:
progress_bar_boss_hp_center.max_value = max_health
if _boss_bar_fill_animating:
if not _boss_bar_fill_tween_started:
_boss_bar_fill_tween_started = true
var tween = create_tween()
tween.tween_property(progress_bar_boss_hp_center, "value", health, 0.5).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD)
tween.finished.connect(func(): _boss_bar_fill_animating = false; _boss_bar_fill_tween_started = false)
else:
progress_bar_boss_hp_center.value = health
if texture_progress_bar_boss_hp: if texture_progress_bar_boss_hp:
texture_progress_bar_boss_hp.max_value = max_health texture_progress_bar_boss_hp.max_value = max_health
texture_progress_bar_boss_hp.value = health texture_progress_bar_boss_hp.value = health
else: else:
# Hide boss health bar if no boss _boss_bar_fill_animating = false
_boss_bar_fill_tween_started = false
if center_bottom_boss:
center_bottom_boss.visible = false
if texture_progress_bar_boss_hp: if texture_progress_bar_boss_hp:
texture_progress_bar_boss_hp.visible = false texture_progress_bar_boss_hp.visible = false
if label_boss: if label_boss:

View File

@@ -40,6 +40,13 @@ var chest_closed_frame: int = -1
var chest_opened_frame: int = -1 var chest_opened_frame: int = -1
var is_chest_opened: bool = false var is_chest_opened: bool = false
# Hidden chest: invisible until detected via perception
var is_hidden: bool = false
var is_detected: bool = false
var player_detection_attempts: Dictionary = {} # peer_id -> true once they've attempted
var player_detection_fail_time: Dictionary = {} # peer_id -> Time.get_ticks_msec() of last failed roll (retry only after 60s)
const HIDDEN_CHEST_RETRY_COOLDOWN_MS: int = 60000 # 60 seconds before allowing another perception check after a fail
# Network sync timer # Network sync timer
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
@@ -73,6 +80,94 @@ func _ready():
shadow.modulate = Color(0, 0, 0, 0.5) shadow.modulate = Color(0, 0, 0, 0.5)
shadow.z_index = -1 shadow.z_index = -1
# Hidden chest: apply initial state (game_world may set is_hidden after we're added)
call_deferred("_apply_hidden_state_if_needed")
func _apply_hidden_state_if_needed() -> void:
if is_hidden and not is_detected:
_apply_hidden_state()
func _apply_hidden_state() -> void:
if is_hidden and not is_detected:
if sprite:
sprite.modulate.a = 0.0
if sprite_above:
sprite_above.modulate.a = 0.0
if shadow:
shadow.visible = false
# Enable detection area and connect if not already
var det = get_node_or_null("DetectionArea")
if det:
if not det.body_entered.is_connected(_on_detection_area_body_entered):
det.body_entered.connect(_on_detection_area_body_entered)
if not det.body_exited.is_connected(_on_detection_area_body_exited):
det.body_exited.connect(_on_detection_area_body_exited)
det.monitoring = true
else:
if sprite:
sprite.modulate.a = 1.0
if sprite_above:
sprite_above.modulate.a = 1.0
if shadow:
shadow.visible = true
func _on_detection_area_body_entered(body: Node) -> void:
if not body.is_in_group("player"):
return
if object_type != "Chest" or not is_hidden or is_detected:
return
var peer_id = body.get_multiplayer_authority() if "get_multiplayer_authority" in body else 0
# If this player already attempted and failed, allow retry only after 60 seconds
if player_detection_attempts.has(peer_id):
var fail_ms: int = player_detection_fail_time.get(peer_id, 0)
if fail_ms > 0 and (Time.get_ticks_msec() - fail_ms) < HIDDEN_CHEST_RETRY_COOLDOWN_MS:
return
# Mark that this player is attempting (or retrying after cooldown)
player_detection_attempts[peer_id] = true
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer():
_roll_hidden_chest_perception(body)
func _on_detection_area_body_exited(_body: Node) -> void:
# No longer erase attempts on exit; retry only after 60s cooldown (checked on re-enter)
pass
func _roll_hidden_chest_perception(player: Node) -> void:
if not player or not player.character_stats:
return
var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per")
var roll = randi() % 20 + 1
var total = roll + int(per_stat / 2) - 5
var dc = 13 # Slightly easier so detection is noticeable (was 15)
if total >= dc:
_detect_hidden_chest(player)
else:
var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
player_detection_fail_time[peer_id] = Time.get_ticks_msec()
func _detect_hidden_chest(detecting_player: Node) -> void:
is_detected = true
_apply_hidden_state()
# Spawn detected effect (synced)
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("spawn_detected_effect_at"):
game_world.spawn_detected_effect_at(global_position)
# Alert and sound for the detecting player only (SfxAhaa + SfxSecretFound)
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_secret_chest_detected"):
if detecting_player.is_multiplayer_authority():
detecting_player._on_secret_chest_detected()
else:
detecting_player._on_secret_chest_detected.rpc_id(detecting_player.get_multiplayer_authority())
# Sync to all clients
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var game_world_sync = get_tree().get_first_node_in_group("game_world")
if game_world_sync and game_world_sync.has_method("_sync_hidden_chest_detected_by_name"):
game_world_sync._sync_hidden_chest_detected_by_name.rpc(name)
func _sync_hidden_chest_detected() -> void:
# Called on clients when server detected this hidden chest
is_detected = true
_apply_hidden_state()
func _physics_process(delta): func _physics_process(delta):
# All clients simulate physics locally for smooth visuals # All clients simulate physics locally for smooth visuals
# Initial throw state is synced via player's _sync_throw RPC # Initial throw state is synced via player's _sync_throw RPC
@@ -81,13 +176,15 @@ 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) # Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink.
if not is_airborne and position_z <= 0.0: if not is_airborne and position_z <= 0.0 and object_type != "Pillar":
var gw = get_tree().get_first_node_in_group("game_world") 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 gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
if not falling_into_fallout: if not falling_into_fallout:
falling_into_fallout = true falling_into_fallout = true
fallout_sink_progress = 1.0 fallout_sink_progress = 1.0
if has_node("SfxFall"):
$SfxFall.play()
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
if fallout_sink_progress <= 0.0: if fallout_sink_progress <= 0.0:
queue_free() queue_free()
@@ -388,6 +485,9 @@ func _is_wall_collider(collider) -> bool:
return false return false
func can_be_grabbed() -> bool: func can_be_grabbed() -> bool:
# Hidden chest cannot be opened until detected
if object_type == "Chest" and is_hidden and not is_detected:
return false
return is_grabbable and not is_being_held return is_grabbable and not is_being_held
func _get_configured_object_type() -> String: func _get_configured_object_type() -> String:
@@ -453,6 +553,9 @@ func take_fire_damage(amount: float, _attacker_position: Vector2) -> void:
func take_damage(amount: float, _from_position: Vector2) -> void: func take_damage(amount: float, _from_position: Vector2) -> void:
"""Generic damage from bomb, frost spike, etc. Any destroyable object.""" """Generic damage from bomb, frost spike, etc. Any destroyable object."""
# Hidden chest cannot be attacked until detected (must be revealed first)
if object_type == "Chest" and is_hidden and not is_detected:
return
if not is_destroyable or is_broken: if not is_destroyable or is_broken:
return return
health -= amount health -= amount
@@ -471,6 +574,9 @@ func take_damage(amount: float, _from_position: Vector2) -> void:
_break_into_pieces() _break_into_pieces()
func on_grabbed(by_player): func on_grabbed(by_player):
# Hidden chest cannot be opened until detected
if object_type == "Chest" and is_hidden and not is_detected:
return
# Special handling for chests - open instead of grab # Special handling for chests - open instead of grab
if object_type == "Chest" and not is_chest_opened: if object_type == "Chest" and not is_chest_opened:
# In multiplayer, send RPC to server if client is opening # In multiplayer, send RPC to server if client is opening
@@ -807,6 +913,13 @@ func _open_chest(by_player: Node = null):
return return
$SfxOpenChest.play() $SfxOpenChest.play()
is_chest_opened = true is_chest_opened = true
# Remove reveal effect when opening a hidden chest (was detected, now opened)
if is_hidden:
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("remove_detected_effect_at_position"):
gw.remove_detected_effect_at_position(global_position)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and gw.has_method("_rpc_to_ready_peers"):
gw._rpc_to_ready_peers("_sync_remove_detected_effect_at_position", [global_position.x, global_position.y])
# Track opened chest for syncing to new clients # Track opened chest for syncing to new clients
if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
@@ -860,12 +973,19 @@ func _open_chest(by_player: Node = null):
if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON: if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON:
candidates.append(item_id) candidates.append(item_id)
# Select random item from candidates using deterministic RNG # Chance for empty chest (no item, no coin, no loot)
var random_item_id = candidates[chest_rng.randi() % candidates.size()] if not candidates.is_empty() else null var empty_roll = chest_rng.randf()
var is_empty_chest = (empty_roll < 0.18)
# Select random item from candidates using deterministic RNG (null if empty)
var random_item_id = null
if not is_empty_chest and not candidates.is_empty():
random_item_id = candidates[chest_rng.randi() % candidates.size()]
var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null
# CRITICAL: Instantly give item to player instead of spawning loot object # CRITICAL: Instantly give item to player instead of spawning loot object (or show EMPTY CHEST)
if by_player and is_instance_valid(by_player) and by_player.is_in_group("player") and chest_item: if by_player and is_instance_valid(by_player) and by_player.is_in_group("player"):
if chest_item:
# Add item to player inventory # Add item to player inventory
if by_player.character_stats: if by_player.character_stats:
var was_encumbered = by_player.character_stats.is_over_encumbered() var was_encumbered = by_player.character_stats.is_over_encumbered()
@@ -892,25 +1012,19 @@ func _open_chest(by_player: Node = null):
_show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) _show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
else: else:
_show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item) _show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item)
# Play get-item sound when receiving inventory item from chest
# Play chest open sound if has_node("SfxGetItemFromChest"):
if has_node("SfxChestOpen"): $SfxGetItemFromChest.play()
$SfxChestOpen.play()
print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name) print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name)
# Sync chest opening visual to all clients (item already given on server) # Sync chest opening visual to all clients (item already given on server)
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
# Reuse game_world from earlier in the function
if game_world and game_world.has_method("_rpc_to_ready_peers"): if game_world and game_world.has_method("_rpc_to_ready_peers"):
var chest_name = name var chest_name = name
if has_meta("object_index"): if has_meta("object_index"):
chest_name = "InteractableObject_%d" % get_meta("object_index") chest_name = "InteractableObject_%d" % get_meta("object_index")
# Sync chest open visual with item_data so clients can show the floating text
var item_data = chest_item.save() if chest_item else {} var item_data = chest_item.save() if chest_item else {}
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data]) game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data])
# Sync inventory+equipment to joiner (server added item; joiner's client must apply)
if multiplayer.is_server(): if multiplayer.is_server():
var owner_id = by_player.get_multiplayer_authority() var owner_id = by_player.get_multiplayer_authority()
if owner_id != 1 and owner_id != multiplayer.get_unique_id(): if owner_id != 1 and owner_id != multiplayer.get_unique_id():
@@ -923,6 +1037,17 @@ func _open_chest(by_player: Node = null):
equip_data[slot_name] = eq.save() if eq else null equip_data[slot_name] = eq.save() if eq else null
if by_player.has_method("_apply_inventory_and_equipment_from_server"): if by_player.has_method("_apply_inventory_and_equipment_from_server"):
by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
else:
# Empty chest: show "EMPTY CHEST" floating text
_show_item_pickup_notification(by_player, "EMPTY CHEST", Color(0.5, 0.5, 0.5))
print(name, " opened by ", by_player.name, " - empty chest")
if multiplayer.has_multiplayer_peer():
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
if game_world and game_world.has_method("_rpc_to_ready_peers"):
var chest_name = name
if has_meta("object_index"):
chest_name = "InteractableObject_%d" % get_meta("object_index")
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "empty", player_peer_id, {}])
else: else:
push_error("Chest: ERROR - No valid player to give item to!") push_error("Chest: ERROR - No valid player to give item to!")
@@ -991,6 +1116,8 @@ func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0, i
_show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) _show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
else: else:
_show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0, chest_item) _show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0, chest_item)
elif loot_type_str == "empty":
_show_item_pickup_notification(player, "EMPTY CHEST", Color(0.5, 0.5, 0.5))
else: else:
# Fallback to old loot type system (for backwards compatibility) # Fallback to old loot type system (for backwards compatibility)
match loot_type_str: match loot_type_str:

View File

@@ -257,18 +257,19 @@ func _update_stats():
str(char_stats.baseStats.lck) + "\n" + \ str(char_stats.baseStats.lck) + "\n" + \
str(char_stats.baseStats.get("per", 10)) str(char_stats.baseStats.get("per", 10))
# Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit% (XP/Coin moved to exp meter & coin UI) # Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit%, Dodge% (XP/Coin moved to exp meter & coin UI)
if label_derived_stats: if label_derived_stats:
label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%" label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%\nDodge%"
if label_derived_stats_value: if label_derived_stats_value:
label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%" % [ label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%\n%.1f%%" % [
char_stats.damage, char_stats.damage,
char_stats.defense, char_stats.defense,
char_stats.move_speed, char_stats.move_speed,
char_stats.attack_speed, char_stats.attack_speed,
char_stats.sight, char_stats.sight,
char_stats.spell_amp, char_stats.spell_amp,
char_stats.crit_chance char_stats.crit_chance,
char_stats.dodge_chance * 100.0
] ]
# HP bar # HP bar
@@ -1679,6 +1680,9 @@ func _use_consumable_item(item: Item):
if item.modifiers.has("mp"): if item.modifiers.has("mp"):
var mana_amount = item.modifiers["mp"] var mana_amount = item.modifiers["mp"]
char_stats.restore_mana(mana_amount) char_stats.restore_mana(mana_amount)
# Dodge potion (and any consumable with dodge_chance + duration): apply temporary dodge buff
if item.modifiers.has("dodge_chance") and item.duration > 0:
char_stats.add_buff_dodge_chance(item.modifiers["dodge_chance"], item.duration)
var index = char_stats.inventory.find(item) var index = char_stats.inventory.find(item)
if index >= 0: if index >= 0:
@@ -1845,3 +1849,6 @@ func _lock_player_controls(lock: bool):
var local_players = player_manager.get_local_players() var local_players = player_manager.get_local_players()
for player in local_players: for player in local_players:
player.controls_disabled = lock player.controls_disabled = lock
if lock:
# Stop movement immediately when opening inventory (physics may have already run this frame)
player.velocity = Vector2.ZERO

View File

@@ -186,7 +186,8 @@ func _update_stats():
str(char_stats.attack_speed) + "\n" + \ str(char_stats.attack_speed) + "\n" + \
str(char_stats.sight) + "\n" + \ str(char_stats.sight) + "\n" + \
str(char_stats.spell_amp) + "\n" + \ str(char_stats.spell_amp) + "\n" + \
str(char_stats.crit_chance) + "%" str(char_stats.crit_chance) + "%\n" + \
("%.1f" % (char_stats.dodge_chance * 100.0)) + "%"
func _create_equipment_slots(): func _create_equipment_slots():
# Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory # Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory

View File

@@ -966,10 +966,7 @@ static func _load_all_items():
"description": "A standard longsword", "description": "A standard longsword",
"item_type": Item.ItemType.Equippable, "item_type": Item.ItemType.Equippable,
"equipment_type": Item.EquipmentType.MAINHAND, "equipment_type": Item.EquipmentType.MAINHAND,
"weapon_type": Item.WeaponType.SWORD,
"spriteFrame": 3 * 20 + 10, # 10,3
"modifiers": {"dmg": 5}, "modifiers": {"dmg": 5},
"buy_cost": 100,
"sell_worth": 30, "sell_worth": 30,
"rarity": ItemRarity.COMMON, "rarity": ItemRarity.COMMON,
"weight": 3.5 "weight": 3.5

View File

@@ -58,7 +58,7 @@ 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) # Fallout: sink and disappear (no recovery); keys respawn on nearest safe tile
var falling_into_fallout: bool = false var falling_into_fallout: bool = false
var fallout_sink_progress: float = 1.0 var fallout_sink_progress: float = 1.0
const FALLOUT_SINK_DURATION: float = 0.4 const FALLOUT_SINK_DURATION: float = 0.4
@@ -234,6 +234,8 @@ func _physics_process(delta):
if not falling_into_fallout: if not falling_into_fallout:
falling_into_fallout = true falling_into_fallout = true
fallout_sink_progress = 1.0 fallout_sink_progress = 1.0
if has_node("SfxFall"):
$SfxFall.play()
# Lock to center of fallout tile and stop all x/y movement # Lock to center of fallout tile and stop all x/y movement
if gw.has_method("_get_tile_center_at"): if gw.has_method("_get_tile_center_at"):
global_position = gw._get_tile_center_at(global_position) global_position = gw._get_tile_center_at(global_position)
@@ -244,6 +246,19 @@ func _physics_process(delta):
pickup_area.monitorable = false pickup_area.monitorable = false
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
if fallout_sink_progress <= 0.0: if fallout_sink_progress <= 0.0:
# Keys respawn on a safe tile (like player); other loot is removed
if loot_type == LootType.KEY:
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_get_nearest_safe_tile_center"):
var safe_pos = game_world._get_nearest_safe_tile_center(global_position)
_respawn_key_at_safe_position(safe_pos)
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
if game_world.has_method("_rpc_to_ready_peers"):
game_world._rpc_to_ready_peers("_sync_key_respawn", [loot_id, safe_pos])
else:
queue_free()
else:
# Sync removal to clients so joiner sees loot disappear (same as pickup) # Sync removal to clients so joiner sees loot disappear (same as pickup)
if multiplayer.has_multiplayer_peer() and is_inside_tree(): if multiplayer.has_multiplayer_peer() and is_inside_tree():
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
@@ -447,6 +462,20 @@ func _animate_coin(delta):
var frame = int(coin_anim_time) % 6 var frame = int(coin_anim_time) % 6
sprite.frame = frame sprite.frame = frame
func _respawn_key_at_safe_position(safe_pos: Vector2):
# Reposition key on a safe (non-fallout) tile and reset state so it can be picked up again
global_position = safe_pos
falling_into_fallout = false
fallout_sink_progress = 1.0
velocity = Vector2.ZERO
velocity_z = 0.0
position_z = SPAWN_POSITION_Z
is_airborne = false
scale = Vector2.ONE
if pickup_area:
pickup_area.monitoring = true
pickup_area.monitorable = true
func _on_pickup_area_body_entered(body): func _on_pickup_area_body_entered(body):
if falling_into_fallout: if falling_into_fallout:
return return

View File

@@ -9,21 +9,24 @@ const COLOR_UNEXPLORED: Color = Color(0.08, 0.08, 0.1)
const COLOR_WALL: Color = Color(0.22, 0.22, 0.26) const COLOR_WALL: Color = Color(0.22, 0.22, 0.26)
const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44) const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44)
const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35) const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35)
const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0) # Blue for other players
const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0) const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0)
var _map_size: Vector2i = Vector2i.ZERO var _map_size: Vector2i = Vector2i.ZERO
var _explored_map: PackedInt32Array = PackedInt32Array() var _explored_map: PackedInt32Array = PackedInt32Array()
var _grid: Array = [] # 2D grid [x][y]: 0=wall, 1=floor, 2=door, 3=corridor var _grid: Array = [] # 2D grid [x][y]: 0=wall, 1=floor, 2=door, 3=corridor
var _player_tile: Vector2i = Vector2i(-1, -1) var _player_tile: Vector2i = Vector2i(-1, -1)
var _other_player_tiles: Array = [] # Array of Vector2i for other players
var _exit_tile: Vector2i = Vector2i(-1, -1) var _exit_tile: Vector2i = Vector2i(-1, -1)
var _exit_discovered: bool = false var _exit_discovered: bool = false
func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false) -> void: func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false, other_player_tiles: Array = []) -> void:
_explored_map = explored_map _explored_map = explored_map
_map_size = map_size _map_size = map_size
_grid = grid _grid = grid
_player_tile = player_tile _player_tile = player_tile
_other_player_tiles = other_player_tiles
_exit_tile = exit_tile _exit_tile = exit_tile
_exit_discovered = exit_discovered _exit_discovered = exit_discovered
queue_redraw() queue_redraw()
@@ -73,6 +76,12 @@ func _draw() -> void:
var py := float(_player_tile.y) * th + th * 0.5 var py := float(_player_tile.y) * th + th * 0.5
var r := maxf(2.0, minf(tw, th) * 0.4) var r := maxf(2.0, minf(tw, th) * 0.4)
draw_circle(Vector2(px, py), r, COLOR_PLAYER) draw_circle(Vector2(px, py), r, COLOR_PLAYER)
for other_tile in _other_player_tiles:
if other_tile is Vector2i and other_tile.x >= 0 and other_tile.y >= 0 and other_tile.x < _map_size.x and other_tile.y < _map_size.y:
var ox := float(other_tile.x) * tw + tw * 0.5
var oy := float(other_tile.y) * th + th * 0.5
var or_ := maxf(1.5, minf(tw, th) * 0.32)
draw_circle(Vector2(ox, oy), or_, COLOR_OTHER_PLAYER)
if _exit_discovered and _exit_tile.x >= 0 and _exit_tile.y >= 0 and _exit_tile.x < _map_size.x and _exit_tile.y < _map_size.y: if _exit_discovered and _exit_tile.x >= 0 and _exit_tile.y >= 0 and _exit_tile.x < _map_size.x and _exit_tile.y < _map_size.y:
var ex := float(_exit_tile.x) * tw + tw * 0.5 var ex := float(_exit_tile.x) * tw + tw * 0.5
var ey := float(_exit_tile.y) * th + th * 0.5 var ey := float(_exit_tile.y) * th + th * 0.5

View File

@@ -37,6 +37,10 @@ var reconnection_attempting: bool = false # Track if we're attempting to reconn
var reconnection_timer: float = 0.0 # Timer for reconnection delay var reconnection_timer: float = 0.0 # Timer for reconnection delay
const RECONNECTION_DELAY: float = 1.0 # Delay before attempting reconnection (reduced from 2.0) const RECONNECTION_DELAY: float = 1.0 # Delay before attempting reconnection (reduced from 2.0)
# Grace period for joiner disconnect: if they reconnect within this time, treat as same peer (don't despawn)
var disconnected_peers: Dictionary = {} # peer_id -> { player_info, disconnect_time }
const DISCONNECT_GRACE_SECONDS: float = 18.0
# Logging - use LogManager for categorized logging # Logging - use LogManager for categorized logging
func log_print(message: String): func log_print(message: String):
LogManager.log(message, LogManager.CATEGORY_NETWORK) LogManager.log(message, LogManager.CATEGORY_NETWORK)
@@ -69,6 +73,23 @@ func _ready():
add_child(room_registry) add_child(room_registry)
room_registry.rooms_fetched.connect(_on_rooms_fetched) room_registry.rooms_fetched.connect(_on_rooms_fetched)
func _finalize_disconnect(peer_id: int) -> void:
if not disconnected_peers.has(peer_id):
return
var data = disconnected_peers[peer_id]
disconnected_peers.erase(peer_id)
if players_info.has(peer_id):
players_info.erase(peer_id)
log_print("NetworkManager: Finalizing disconnect for peer " + str(peer_id) + " (grace period ended)")
player_disconnected.emit(peer_id, data.player_info)
if room_registry and not room_id.is_empty():
var player_count = get_all_player_ids().size()
var _level = 1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
_level = game_world.current_level
room_registry.send_room_update(player_count)
func set_network_mode(mode: int): func set_network_mode(mode: int):
# 0 = ENet, 1 = WebRTC, 2 = WebSocket # 0 = ENet, 1 = WebRTC, 2 = WebSocket
# WebRTC is now available on native platforms with webrtc-native extension # WebRTC is now available on native platforms with webrtc-native extension
@@ -341,7 +362,18 @@ func _generate_player_names(count: int, peer_id: int) -> Array:
# Called when a peer connects to the server # Called when a peer connects to the server
func _on_peer_connected(id: int): func _on_peer_connected(id: int):
log_print("Peer connected: " + str(id)) log_print("Peer connected: " + str(id))
# Peer is now actually available for RPCs - emit player_connected signal # Reconnection: if this peer was in grace period, treat as reconnection - don't emit player_connected (players still exist)
if is_hosting and disconnected_peers.has(id):
disconnected_peers.erase(id)
log_print("NetworkManager: Peer " + str(id) + " reconnected within grace period - keeping existing players")
if room_registry and not room_id.is_empty():
var player_count = get_all_player_ids().size()
var _level = 1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
_level = game_world.current_level
room_registry.send_room_update(player_count)
return
# Make sure player info is registered (should have been done in _on_matchbox_peer_connected) # Make sure player info is registered (should have been done in _on_matchbox_peer_connected)
if not players_info.has(id): if not players_info.has(id):
# Fallback: register default player info if not already registered # Fallback: register default player info if not already registered
@@ -349,18 +381,12 @@ func _on_peer_connected(id: int):
"local_player_count": 1, # Default, will be updated via RPC "local_player_count": 1, # Default, will be updated via RPC
"player_names": _generate_player_names(1, id) "player_names": _generate_player_names(1, id)
} }
# For joiners, emit connection_succeeded when the first peer (host) connects # For joiners, emit connection_succeeded when the first peer (host) connects
# This ensures the connection is actually established and RPCs can be received
# Note: On web, multiplayer.peer_connected might not fire automatically,
# so we also check in _on_matchbox_peer_connected as a fallback
if not is_hosting and id == 1: # Host connected to joiner if not is_hosting and id == 1: # Host connected to joiner
log_print("NetworkManager: Joiner - host connected via multiplayer.peer_connected, emitting connection_succeeded") log_print("NetworkManager: Joiner - host connected via multiplayer.peer_connected, emitting connection_succeeded")
connection_succeeded.emit() connection_succeeded.emit()
# Emit player connected signal (peer is now available for RPCs) # Emit player connected signal (peer is now available for RPCs)
player_connected.emit(id, players_info[id]) player_connected.emit(id, players_info[id])
# Update room registry if hosting (player count changed) # Update room registry if hosting (player count changed)
if is_hosting and room_registry and not room_id.is_empty(): if is_hosting and room_registry and not room_id.is_empty():
var player_count = get_all_player_ids().size() var player_count = get_all_player_ids().size()
@@ -373,20 +399,22 @@ func _on_peer_connected(id: int):
# Called when a peer disconnects # Called when a peer disconnects
func _on_peer_disconnected(id: int): func _on_peer_disconnected(id: int):
log_print("Peer disconnected: " + str(id)) log_print("Peer disconnected: " + str(id))
# Get player_info before erasing it # If joiner lost host, trigger server disconnection immediately (no grace)
var player_info = {}
if players_info.has(id):
player_info = players_info[id]
players_info.erase(id)
player_disconnected.emit(id, player_info)
# If joiner and host (peer ID 1) disconnected, trigger server disconnection logic
if not is_hosting and id == 1: if not is_hosting and id == 1:
log_print("NetworkManager: Host (peer ID 1) disconnected, triggering reconnection...") log_print("NetworkManager: Host (peer ID 1) disconnected, triggering reconnection...")
_on_server_disconnected() _on_server_disconnected()
return return
# Host: when a joiner disconnects, use grace period so brief drops don't "recreate" them as new
# Update room registry if hosting (player count changed) if is_hosting and players_info.has(id):
var pinfo = players_info[id]
disconnected_peers[id] = { "player_info": pinfo, "disconnect_time": Time.get_ticks_msec() / 1000.0 }
log_print("NetworkManager: Peer " + str(id) + " in grace period for " + str(DISCONNECT_GRACE_SECONDS) + "s (reconnect = same player)")
return
# Fallback: no players_info (e.g. never fully registered) or not host - finalize immediately
var player_info = players_info.get(id, {})
if players_info.has(id):
players_info.erase(id)
player_disconnected.emit(id, player_info)
if is_hosting and room_registry and not room_id.is_empty(): if is_hosting and room_registry and not room_id.is_empty():
var player_count = get_all_player_ids().size() var player_count = get_all_player_ids().size()
var _level = 1 var _level = 1
@@ -422,6 +450,9 @@ func _on_connection_failed():
# Called on client when disconnected from server # Called on client when disconnected from server
func _on_server_disconnected(): func _on_server_disconnected():
log_print("Server disconnected") log_print("Server disconnected")
# Clear so reconnection can emit connection_succeeded again (hides "Disconnected - Reconnecting..." label)
if has_meta("connection_succeeded_timer_set"):
remove_meta("connection_succeeded_timer_set")
# Store reconnection info if we're a joiner (not host) # Store reconnection info if we're a joiner (not host)
if not is_hosting and not room_id.is_empty(): if not is_hosting and not room_id.is_empty():
@@ -591,6 +622,12 @@ func _on_matchbox_peer_connected(peer_id: int):
# For joiners, if this is the host (peer_id 1) and connection_succeeded hasn't been emitted yet, # For joiners, if this is the host (peer_id 1) and connection_succeeded hasn't been emitted yet,
# wait a bit and then emit it as a fallback (in case multiplayer.peer_connected doesn't fire on web) # wait a bit and then emit it as a fallback (in case multiplayer.peer_connected doesn't fire on web)
if not is_hosting and peer_id == 1: if not is_hosting and peer_id == 1:
# If we're already in game scene (reconnection), emit immediately to hide "Disconnected - Reconnecting..." label
var gw = get_tree().get_first_node_in_group("game_world")
if gw:
log_print("NetworkManager: Joiner - host connected via Matchbox (in game scene), emitting connection_succeeded for reconnection")
connection_succeeded.emit()
return
# Check if we've already set up a timer for this peer (prevent multiple timers) # Check if we've already set up a timer for this peer (prevent multiple timers)
if has_meta("connection_succeeded_timer_set"): if has_meta("connection_succeeded_timer_set"):
log_print("NetworkManager: Joiner - timer already set for peer " + str(peer_id) + ", skipping") log_print("NetworkManager: Joiner - timer already set for peer " + str(peer_id) + ", skipping")
@@ -666,7 +703,10 @@ func _on_matchbox_peer_connected(peer_id: int):
log_print("NetworkManager: Attempting to change scene directly to game_world.tscn") log_print("NetworkManager: Attempting to change scene directly to game_world.tscn")
current_tree.change_scene_to_packed(game_world_scene) current_tree.change_scene_to_packed(game_world_scene)
else: else:
log_print("NetworkManager: Joiner - already in game scene, connection_succeeded was already emitted") # In game scene: either initial join (peer_connected already fired) or RECONNECTION.
# On reconnection we never emitted connection_succeeded, so emit it now to hide "Disconnected - Reconnecting..." label.
log_print("NetworkManager: Joiner - in game scene, emitting connection_succeeded (reconnection or fallback)")
connection_succeeded.emit()
) )
# On web, multiplayer.peer_connected might not fire for either host or joiner # On web, multiplayer.peer_connected might not fire for either host or joiner
@@ -747,7 +787,17 @@ func get_room_id() -> String:
return room_id return room_id
func _process(delta: float): func _process(delta: float):
# Handle reconnection timer # Finalize disconnect for peers that didn't reconnect within grace period (host only)
if is_hosting and not disconnected_peers.is_empty():
var now = Time.get_ticks_msec() / 1000.0
var to_finalize = []
for pid in disconnected_peers.keys():
var data = disconnected_peers[pid]
if now - data.disconnect_time >= DISCONNECT_GRACE_SECONDS:
to_finalize.append(pid)
for pid in to_finalize:
_finalize_disconnect(pid)
# Handle reconnection timer (joiner only)
if reconnection_attempting: if reconnection_attempting:
reconnection_timer -= delta reconnection_timer -= delta
if reconnection_timer <= 0.0: if reconnection_timer <= 0.0:
@@ -827,6 +877,43 @@ func _generate_room_id() -> String:
code += chars[randi() % chars.length()] code += chars[randi() % chars.length()]
return code return code
# Pending boss spider spawns (client only): when RPC arrives before GameWorld is in tree, store and apply when ready
var pending_boss_spider_spawns: Array = [] # [{p1x, p1y, p2x, p2y, p3x, p3y, idx0, idx1, idx2}, ...]
# Boss spider spawn: relay RPC so joiner always receives it (autoload exists on all peers)
@rpc("authority", "reliable")
func spawn_boss_spiders_client(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void:
if multiplayer.is_server():
return
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.has_method("_do_client_spawn_boss_spiders"):
# Joiner may not have GameWorld in tree yet; queue and apply when GameWorld calls apply_pending_boss_spider_spawns
pending_boss_spider_spawns.append({"p1x": p1x, "p1y": p1y, "p2x": p2x, "p2y": p2y, "p3x": p3x, "p3y": p3y, "idx0": idx0, "idx1": idx1, "idx2": idx2})
# Schedule retry so we apply when GameWorld appears (joiner may load game scene later)
get_tree().create_timer(3.0).timeout.connect(func():
if pending_boss_spider_spawns.is_empty():
return
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_do_client_spawn_boss_spiders"):
apply_pending_boss_spider_spawns(gw)
)
return
game_world._do_client_spawn_boss_spiders(p1x, p1y, p2x, p2y, p3x, p3y, idx0, idx1, idx2)
func _deferred_spawn_boss_spiders(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void:
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.has_method("_do_client_spawn_boss_spiders"):
return
game_world._do_client_spawn_boss_spiders(p1x, p1y, p2x, p2y, p3x, p3y, idx0, idx1, idx2)
func apply_pending_boss_spider_spawns(game_world: Node) -> void:
"""Called by GameWorld when it becomes ready; spawn any boss spiders that arrived before we were in tree."""
if not game_world or not game_world.has_method("_do_client_spawn_boss_spiders"):
return
while pending_boss_spider_spawns.size() > 0:
var entry = pending_boss_spider_spawns.pop_front()
game_world._do_client_spawn_boss_spiders(entry.p1x, entry.p1y, entry.p2x, entry.p2y, entry.p3x, entry.p3y, entry.idx0, entry.idx1, entry.idx2)
func get_webrtc_peer() -> WebRTCMultiplayerPeer: func get_webrtc_peer() -> WebRTCMultiplayerPeer:
if network_mode == 1 and multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: if network_mode == 1 and multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:

View File

@@ -72,6 +72,10 @@ var is_knocked_back: bool = false
var knockback_time: float = 0.0 var knockback_time: float = 0.0
var knockback_duration: float = 0.3 # How long knockback lasts var knockback_duration: float = 0.3 # How long knockback lasts
# Web net (boss): when netted_by_web != null, player is stuck and cannot attack with main weapon
var netted_by_web: Node = null
var netted_overlay_sprite: Sprite2D = null # Frame 679 for netted visual
# Attack/Combat # Attack/Combat
var can_attack: bool = true var can_attack: bool = true
var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var attack_cooldown: float = 0.0 # No cooldown - instant attacks!
@@ -182,6 +186,7 @@ const HELD_POSITION_Z: float = 12.0 # Z height when held/lifted (above ground; i
@onready var sfx_die = $SfxDie @onready var sfx_die = $SfxDie
@onready var sfx_look_out = $SfxLookOut @onready var sfx_look_out = $SfxLookOut
@onready var sfx_ahaa = $SfxAhaa @onready var sfx_ahaa = $SfxAhaa
@onready var sfx_secret_found = $SfxSecretFound
# Alert indicator (exclamation mark) # Alert indicator (exclamation mark)
var alert_indicator: Sprite2D = null var alert_indicator: Sprite2D = null
@@ -1493,32 +1498,23 @@ func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool:
placed_shape_transform = Transform2D(0.0, place_pos) placed_shape_transform = Transform2D(0.0, place_pos)
# Check if the placed object's collision shape would collide with anything # Check if the placed object's collision shape would collide with anything
# This includes: walls, other objects, and players # This includes: walls, other objects, and players (not Area2D triggers - those don't block placement)
var params = PhysicsShapeQueryParameters2D.new() var params = PhysicsShapeQueryParameters2D.new()
params.shape = placed_shape params.shape = placed_shape
params.transform = placed_shape_transform params.transform = placed_shape_transform
params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64) params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64)
params.collide_with_areas = false # Only solid bodies block; ignore trigger areas (e.g. door key zones)
params.collide_with_bodies = true
# CRITICAL: Exclude self, the object being placed, and make sure to exclude it properly # CRITICAL: Exclude using RIDs so the physics engine actually excludes them (Node refs may not work)
# The object might still be in the scene tree with collision disabled, so we need to exclude it var exclude_list: Array[RID] = [get_rid()]
var exclude_list = [self] if placed_obj and is_instance_valid(placed_obj) and placed_obj is CollisionObject2D:
if placed_obj and is_instance_valid(placed_obj): exclude_list.append(placed_obj.get_rid())
exclude_list.append(placed_obj)
params.exclude = exclude_list params.exclude = exclude_list
# Test the actual collision shape at the placement position # Test the actual collision shape at the placement position
var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions
# Debug: Log what we found
if hits.size() > 0:
print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos)
for i in min(hits.size(), 3): # Log first 3 collisions
var hit = hits[i]
if hit.has("collider"):
print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")")
if hit.has("rid"):
print(" - RID: ", hit.rid)
# If any collisions found, placement is invalid # If any collisions found, placement is invalid
return hits.size() == 0 return hits.size() == 0
@@ -1964,6 +1960,8 @@ func _physics_process(delta):
const MANA_REGEN_RATE = 2.0 # mana per second const MANA_REGEN_RATE = 2.0 # mana per second
if character_stats.mp < character_stats.maxmp: if character_stats.mp < character_stats.maxmp:
character_stats.restore_mana(MANA_REGEN_RATE * delta) character_stats.restore_mana(MANA_REGEN_RATE * delta)
# Tick down temporary buffs (e.g. dodge potion)
character_stats.tick_buffs(delta)
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse # Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
if is_charging_spell: if is_charging_spell:
@@ -2496,8 +2494,8 @@ func _handle_input():
if new_direction != current_direction: if new_direction != current_direction:
current_direction = new_direction current_direction = new_direction
_update_cone_light_rotation() _update_cone_light_rotation()
elif is_pushing: elif is_pushing or (held_object and not is_lifting):
# Keep locked direction when pushing # Keep direction from when grab started (don't turn to face the object)
if push_direction_locked != current_direction: if push_direction_locked != current_direction:
current_direction = push_direction_locked as Direction current_direction = push_direction_locked as Direction
_update_cone_light_rotation() _update_cone_light_rotation()
@@ -2539,9 +2537,10 @@ func _handle_input():
elif is_lifting: elif is_lifting:
if current_animation != "LIFT" and current_animation != "IDLE_HOLD": if current_animation != "LIFT" and current_animation != "IDLE_HOLD":
_set_animation("IDLE_HOLD") _set_animation("IDLE_HOLD")
elif is_pushing: elif is_pushing or (held_object and not is_lifting):
if is_pushing:
_set_animation("IDLE_PUSH") _set_animation("IDLE_PUSH")
# Keep locked direction when pushing # Keep direction from when grab started
if push_direction_locked != current_direction: if push_direction_locked != current_direction:
current_direction = push_direction_locked as Direction current_direction = push_direction_locked as Direction
_update_cone_light_rotation() _update_cone_light_rotation()
@@ -2598,8 +2597,8 @@ func _handle_input():
if character_stats and character_stats.is_over_encumbered(): if character_stats and character_stats.is_over_encumbered():
current_speed = base_speed * 0.25 current_speed = base_speed * 0.25
# Lock movement if movement_lock_timer is active or reviving a corpse # Lock movement if movement_lock_timer is active, reviving a corpse, or netted by web
if movement_lock_timer > 0.0 or is_reviving: if movement_lock_timer > 0.0 or is_reviving or netted_by_web:
velocity = Vector2.ZERO velocity = Vector2.ZERO
else: else:
velocity = input_vector * current_speed velocity = input_vector * current_speed
@@ -2623,6 +2622,32 @@ func _handle_walking_sfx():
if sfx_walk and sfx_walk.playing: if sfx_walk and sfx_walk.playing:
sfx_walk.stop() sfx_walk.stop()
func _web_net_apply(web_node: Node) -> void:
netted_by_web = web_node
func _web_net_release(_web_node: Node) -> void:
netted_by_web = null
_web_net_show_netted_frame(false)
func _web_net_show_netted_frame(show_net: bool) -> void:
if show_net:
if netted_overlay_sprite == null:
netted_overlay_sprite = Sprite2D.new()
var tex = load("res://assets/gfx/fx/shade_spell_effects.png") as Texture2D
if tex:
netted_overlay_sprite.texture = tex
netted_overlay_sprite.hframes = 105
netted_overlay_sprite.vframes = 79
netted_overlay_sprite.frame = 679
netted_overlay_sprite.centered = true
netted_overlay_sprite.z_index = 5
add_child(netted_overlay_sprite)
if netted_overlay_sprite:
netted_overlay_sprite.visible = true
else:
if netted_overlay_sprite:
netted_overlay_sprite.visible = false
func _handle_interactions(): func _handle_interactions():
var grab_button_down = false var grab_button_down = false
var grab_just_pressed = false var grab_just_pressed = false
@@ -3192,7 +3217,7 @@ func _handle_interactions():
# Handle bow charging # Handle bow charging
if has_bow_and_arrows and not is_lifting and not is_pushing: if has_bow_and_arrows and not is_lifting and not is_pushing:
if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing: if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing and not netted_by_web:
if !$SfxBuckleBow.playing: if !$SfxBuckleBow.playing:
$SfxBuckleBow.play() $SfxBuckleBow.play()
# Start charging bow # Start charging bow
@@ -3260,7 +3285,7 @@ func _handle_interactions():
# Normal attack (non-bow or no arrows) # Normal attack (non-bow or no arrows)
# Also allow throwing when lifting (even if bow is equipped). Block during spawn fall. # Also allow throwing when lifting (even if bow is equipped). Block during spawn fall.
if attack_just_pressed and can_attack and not spawn_landing: if attack_just_pressed and can_attack and not spawn_landing and not netted_by_web:
if is_lifting: if is_lifting:
# Attack while lifting -> throw immediately in facing direction # Attack while lifting -> throw immediately in facing direction
_force_throw_held_object(facing_direction_vector) _force_throw_held_object(facing_direction_vector)
@@ -3382,11 +3407,12 @@ func _try_grab():
# Store the distance from player to object when grabbed (for placement) # Store the distance from player to object when grabbed (for placement)
grab_distance = global_position.distance_to(closest_body.global_position) grab_distance = global_position.distance_to(closest_body.global_position)
# Calculate push axis from grab direction (but don't move the object yet) # Use player's current facing when grab started (do not turn to face the object)
var grab_direction = grab_offset.normalized() var grab_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized()
if grab_direction.length() < 0.1: if grab_direction.length() < 0.1:
grab_direction = last_movement_direction grab_direction = Vector2.DOWN
push_axis = _snap_to_8_directions(grab_direction) push_axis = _snap_to_8_directions(grab_direction)
push_direction_locked = _get_direction_from_vector(push_axis) as Direction
# Disable collision with players and other objects when grabbing # Disable collision with players and other objects when grabbing
# But keep collision with walls (layer 7) enabled for pushing # But keep collision with walls (layer 7) enabled for pushing
@@ -3503,15 +3529,11 @@ func _start_pushing():
is_pushing = true is_pushing = true
is_lifting = false is_lifting = false
# Lock to the direction we're facing when we start pushing # Keep the direction we had when we started the grab (do not face the object)
var initial_direction = grab_offset.normalized() var initial_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized()
if initial_direction.length() < 0.1: if initial_direction.length() < 0.1:
initial_direction = last_movement_direction.normalized() initial_direction = Vector2.DOWN
# Snap to one of 8 directions
push_axis = _snap_to_8_directions(initial_direction) push_axis = _snap_to_8_directions(initial_direction)
# Lock the facing direction (for both animation and attacks)
push_direction_locked = _get_direction_from_vector(push_axis) push_direction_locked = _get_direction_from_vector(push_axis)
facing_direction_vector = push_axis.normalized() facing_direction_vector = push_axis.normalized()
@@ -4045,7 +4067,7 @@ func _place_down_object():
print("Placed down ", placed_obj.name, " at ", place_pos) print("Placed down ", placed_obj.name, " at ", place_pos)
func _perform_attack(): func _perform_attack():
if not can_attack or is_attacking or spawn_landing: if not can_attack or is_attacking or spawn_landing or netted_by_web:
return return
can_attack = false can_attack = false
@@ -6387,6 +6409,13 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
# Invulnerable during fallout sink (can't take damage from anything while falling) # Invulnerable during fallout sink (can't take damage from anything while falling)
if fallout_state: if fallout_state:
return return
# Taking damage while webbed immediately frees you from the web
if netted_by_web:
var web = netted_by_web
netted_by_web = null
_web_net_show_netted_frame(false)
if web and is_instance_valid(web) and web.has_method("cut_by_attack"):
web.cut_by_attack(null)
# Cancel bow charging when taking damage # Cancel bow charging when taking damage
if is_charging_bow: if is_charging_bow:
@@ -6552,10 +6581,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
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():
_rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position]) _rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position])
# Check if dead - but wait for damage animation to play first # Check if dead - below 1 HP must always trigger death (trap, etc.)
# Use small epsilon to handle floating point precision issues (HP might be 0.0000001 instead of exactly 0.0)
var health = character_stats.hp if character_stats else current_health var health = character_stats.hp if character_stats else current_health
if health <= 0.001: # Use epsilon to catch values very close to 0 if health < 1.0:
if character_stats: if character_stats:
character_stats.hp = 0.0 # Clamp to exactly 0 character_stats.hp = 0.0 # Clamp to exactly 0
else: else:
@@ -6563,6 +6591,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
is_dead = true # Set flag immediately to prevent more damage is_dead = true # Set flag immediately to prevent more damage
# Wait a bit for damage animation and knockback to show # Wait a bit for damage animation and knockback to show
await get_tree().create_timer(0.3).timeout await get_tree().create_timer(0.3).timeout
if is_instance_valid(self) and is_dead:
_die() _die()
func _die(): func _die():
@@ -7575,6 +7604,10 @@ func _on_level_up_stats(stats_increased: Array):
if not character_stats: if not character_stats:
return return
# Play level-up fanfare locally only when this player (you) gained the level
if is_multiplayer_authority() and has_node("SfxLevelUp"):
$SfxLevelUp.play()
# Stat name to display name mapping # Stat name to display name mapping
var stat_display_names = { var stat_display_names = {
"str": "STR", "str": "STR",
@@ -7651,6 +7684,17 @@ func rpc_apply_corpse_knockback(dir_x: float, dir_y: float, force: float):
is_knocked_back = true is_knocked_back = true
knockback_time = 0.0 knockback_time = 0.0
@rpc("any_peer", "reliable")
func _on_attack_blocked_by_enemy(blocker_position: Vector2):
# Called when this player's attack was blocked by an enemy (e.g. humanoid with shield). Show BLOCKED and small knockback.
var dir_away = (global_position - blocker_position).normalized()
if dir_away.length() < 0.01:
dir_away = Vector2.RIGHT
velocity = dir_away * 75.0
is_knocked_back = true
knockback_time = 0.0
_show_damage_number(0.0, blocker_position, false, false, false, true)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false): func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
# This RPC only syncs visual effects, not damage application # This RPC only syncs visual effects, not damage application
@@ -7796,6 +7840,26 @@ func _sync_trap_detected_alert():
if sfx_look_out: if sfx_look_out:
sfx_look_out.play() sfx_look_out.play()
@rpc("any_peer", "reliable")
func _on_cracked_floor_detected():
# Called when this player detects a cracked floor (perception roll success). Only the detecting player plays SfxLookOut and sees alert.
if not is_multiplayer_authority():
return
_show_alert_indicator()
if sfx_look_out:
sfx_look_out.play()
@rpc("any_peer", "reliable")
func _on_secret_chest_detected():
# Called when this player detects a hidden chest (perception roll success). Only the detecting player plays SfxAhaa + SfxSecretFound and sees alert.
if not is_multiplayer_authority():
return
_show_alert_indicator()
if sfx_ahaa:
sfx_ahaa.play()
if sfx_secret_found:
sfx_secret_found.play()
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _sync_exit_found_alert(): func _sync_exit_found_alert():
# Sync exit found alert to all clients # Sync exit found alert to all clients

View File

@@ -15,15 +15,19 @@ var elapsed_time: float = 0.0
var distance_traveled: float = 0.0 var distance_traveled: float = 0.0
var player_owner: Node = null var player_owner: Node = null
var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup)
var _cut_web_rpc_sent: bool = false
@onready var sprite = $Sprite2D @onready var sprite = $Sprite2D
@onready var hit_area = $Area2D @onready var hit_area = $Area2D
func _ready(): func _ready():
$SfxSwosh.play() $SfxSwosh.play()
# Connect area signals (only if not already connected) if hit_area:
if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): if not hit_area.body_entered.is_connected(_on_body_entered):
hit_area.body_entered.connect(_on_body_entered) hit_area.body_entered.connect(_on_body_entered)
if not hit_area.area_entered.is_connected(_on_area_entered):
hit_area.area_entered.connect(_on_area_entered)
hit_area.collision_mask = hit_area.collision_mask | 8
func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0): func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0):
travel_direction = direction.normalized() travel_direction = direction.normalized()
@@ -52,6 +56,13 @@ func _physics_process(delta):
current_speed -= deceleration * delta current_speed -= deceleration * delta
current_speed = max(0.0, current_speed) # Don't go negative current_speed = max(0.0, current_speed) # Don't go negative
# So other player can free netted teammate: notify server to cut web
if not _cut_web_rpc_sent and multiplayer.has_multiplayer_peer() and not multiplayer.is_server() and player_owner and is_instance_valid(player_owner) and elapsed_time >= 0.04:
_cut_web_rpc_sent = true
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("request_cut_web"):
gw.request_cut_web.rpc_id(1, global_position.x, global_position.y, 30.0, player_owner.get_multiplayer_authority())
# Move in travel direction # Move in travel direction
var movement = travel_direction * current_speed * delta var movement = travel_direction * current_speed * delta
global_position += movement global_position += movement
@@ -62,6 +73,11 @@ func _physics_process(delta):
if sprite: if sprite:
sprite.modulate.a = alpha sprite.modulate.a = alpha
func _on_area_entered(area: Area2D) -> void:
if area.has_method("cut_by_attack") and area.get("state") == "hit_player":
if player_owner and is_instance_valid(player_owner) and player_owner != area.get("hit_player"):
area.cut_by_attack(player_owner)
func _on_body_entered(body): func _on_body_entered(body):
# Don't hit the owner # Don't hit the owner
if body == player_owner: if body == player_owner:
@@ -134,6 +150,9 @@ func _on_body_entered(body):
else: else:
# Client sends RPC to server # Client sends RPC to server
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit) game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit)
# Client: show damage number locally (boss/enemy won't show it on our view otherwise)
if game_world and game_world.has_method("show_damage_number_at_position") and not multiplayer.is_server():
game_world.show_damage_number_at_position(body.global_position, damage, is_crit)
else: else:
# Fallback: try direct call (may fail if node path doesn't match) # Fallback: try direct call (may fail if node path doesn't match)
var enemy_peer_id = body.get_multiplayer_authority() var enemy_peer_id = body.get_multiplayer_authority()

View File

@@ -1,10 +1,16 @@
extends Node2D extends Node2D
@export var is_enabled = true # set to disabled for keydoors! @export var is_enabled = true: # set to disabled for keydoors!
set(value):
is_enabled = value
if not value and is_node_ready():
$GPUParticles2D.emitting = false
# Called when the node enters the scene tree for the first time. # Called when the node enters the scene tree for the first time.
func _ready() -> void: func _ready() -> void:
pass # Replace with function body. # If we were disabled before _ready (e.g. by door), stop particles once node is ready
if not is_enabled:
$GPUParticles2D.emitting = false
# Called every frame. 'delta' is the elapsed time since the previous frame. # Called every frame. 'delta' is the elapsed time since the previous frame.

View File

@@ -143,13 +143,18 @@ func _detect_trap(detecting_player: Node) -> void:
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"): if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"):
detecting_player._on_trap_detected() detecting_player._on_trap_detected()
# Spawn detected effect at trap position (synced so all players see it; parented to trap so it can be removed when disarmed)
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("spawn_detected_effect_at"):
game_world.spawn_detected_effect_at(global_position, name, "trap")
# Sync detection to all clients (including server with call_local) # Sync detection to all clients (including server with call_local)
# CRITICAL: Validate trap is still valid before sending RPC # CRITICAL: Validate trap is still valid before sending RPC
# Use GameWorld RPC to avoid node path issues # Use GameWorld RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
if multiplayer.is_server(): if multiplayer.is_server():
# Use GameWorld RPC with trap name instead of path # Use GameWorld RPC with trap name instead of path
var game_world = get_tree().get_first_node_in_group("game_world") game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_trap_state_by_name"): if game_world and game_world.has_method("_sync_trap_state_by_name"):
game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false
@@ -212,6 +217,13 @@ func _show_disarm_text(_player: Node) -> void:
disarm_label.z_index = 100 disarm_label.z_index = 100
add_child(disarm_label) add_child(disarm_label)
func _remove_detected_effect() -> void:
# Remove the blue "detected" effect when trap is disarmed (effect is parented to this trap)
for c in get_children():
if c.name == "DetectedEffect":
c.queue_free()
return
func _hide_disarm_text(_player: Node) -> void: func _hide_disarm_text(_player: Node) -> void:
if disarm_label: if disarm_label:
disarm_label.queue_free() disarm_label.queue_free()
@@ -246,6 +258,12 @@ func _complete_disarm() -> void:
if $SfxDisarming.playing: if $SfxDisarming.playing:
$SfxDisarming.stop() $SfxDisarming.stop()
if has_node("SfxDisarmSuccess"):
$SfxDisarmSuccess.play()
# Remove detected effect (blue glow) since trap is no longer dangerous
_remove_detected_effect()
# Hide disarm text # Hide disarm text
_hide_disarm_text(null) _hide_disarm_text(null)
@@ -318,6 +336,7 @@ func _sync_trap_disarmed() -> void:
if not is_instance_valid(self) or not is_inside_tree(): if not is_instance_valid(self) or not is_inside_tree():
return return
is_disarmed = true is_disarmed = true
_remove_detected_effect()
if sprite: if sprite:
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
if activation_area: if activation_area:
@@ -424,10 +443,16 @@ func _deal_trap_damage(player: Node) -> void:
_show_floating_text("AVOIDED", Color.GREEN) _show_floating_text("AVOIDED", Color.GREEN)
return return
# Apply trap damage (affected by player's defense) # Apply trap damage (affected by player's defense) - must run on player's authority so take_damage and death run correctly
var final_damage = player.character_stats.calculate_damage(trap_damage, false, false) var final_damage = player.character_stats.calculate_damage(trap_damage, false, false)
if player.has_method("rpc_take_damage"): if player.has_method("rpc_take_damage"):
var pid = player.get_multiplayer_authority()
if multiplayer.get_unique_id() == pid:
player.take_damage(trap_damage, global_position)
elif multiplayer.has_multiplayer_peer() and pid != 0:
player.rpc_take_damage.rpc_id(pid, trap_damage, global_position)
else:
player.rpc_take_damage(trap_damage, global_position) player.rpc_take_damage(trap_damage, global_position)
print(player.name, " took ", final_damage, " trap damage") print(player.name, " took ", final_damage, " trap damage")