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:
hit_area.body_entered.connect(_on_body_entered) if not hit_area.body_entered.is_connected(_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

@@ -4,7 +4,7 @@ extends Resource
signal health_changed(new_health: float, max_health: float) signal health_changed(new_health: float, max_health: float)
signal mana_changed(new_mana: float, max_mana: float) signal mana_changed(new_mana: float, max_mana: float)
signal level_changed(new_level: int) signal level_changed(new_level: int)
signal level_up_stats(stats_increased: Array) # Emitted when leveling up, contains array of stat names that were increased signal level_up_stats(stats_increased: Array) # Emitted when leveling up, contains array of stat names that were increased
signal xp_changed(new_xp: float, xp_to_next: float) signal xp_changed(new_xp: float, xp_to_next: float)
signal no_health signal no_health
@@ -13,7 +13,7 @@ signal character_changed(char: CharacterStats)
signal signal_drop_item(item: Item) signal signal_drop_item(item: Item)
var character_type: String = "enemy" var character_type: String = "enemy"
var race: String = "Human" # "Dwarf", "Elf", or "Human" var race: String = "Human" # "Dwarf", "Elf", or "Human"
@export var level: int = 1 @export var level: int = 1
@export var character_name: String = "" @export var character_name: String = ""
@export var xp: float = 0 @export var xp: float = 0
@@ -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,
@@ -79,7 +79,7 @@ var equipment:Dictionary = {
"wis": 10, "wis": 10,
"cha": 10, "cha": 10,
"lck": 10, "lck": 10,
"per": 10 # Perception - affects trap detection "per": 10 # Perception - affects trap detection
} }
@export var def: int = 0 @export var def: int = 0
@@ -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
@@ -307,7 +330,7 @@ func calculate_damage(base_damage: float, is_magical: bool = false, is_critical:
if not is_magical: if not is_magical:
# Physical damage: defense provides percentage reduction (like D&D armor) # Physical damage: defense provides percentage reduction (like D&D armor)
# Defense value is converted to percentage: 1 DEF = 2% reduction, max 50% reduction # Defense value is converted to percentage: 1 DEF = 2% reduction, max 50% reduction
var defense_percentage = min(0.5, defense * 0.02) # Max 50% reduction var defense_percentage = min(0.5, defense * 0.02) # Max 50% reduction
var effective_defense = defense_percentage var effective_defense = defense_percentage
if is_critical: if is_critical:
# Critical hits pierce 80% of DEF (only 20% applies) # Critical hits pierce 80% of DEF (only 20% applies)
@@ -327,7 +350,7 @@ func take_damage(amount: float, is_magical: bool = false) -> float:
modify_health(-actual_damage) modify_health(-actual_damage)
# Check if dead (use epsilon to handle floating point precision) # Check if dead (use epsilon to handle floating point precision)
if hp <= 0.001: if hp <= 0.001:
hp = 0.0 # Ensure exactly 0 hp = 0.0 # Ensure exactly 0
no_health.emit() # Emit when health reaches 0 no_health.emit() # Emit when health reaches 0
character_changed.emit(self) character_changed.emit(self)
return actual_damage return actual_damage
@@ -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):
@@ -373,7 +396,7 @@ func loadEquipment(iDic: Dictionary):
equipment["offhand"] = Item.new(iDic.get("offhand")) if iDic.has("offhand") and iDic.get("offhand") != null else null equipment["offhand"] = Item.new(iDic.get("offhand")) if iDic.has("offhand") and iDic.get("offhand") != null else null
equipment["headgear"] = Item.new(iDic.get("headgear")) if iDic.has("headgear") and iDic.get("headgear") != null else null equipment["headgear"] = Item.new(iDic.get("headgear")) if iDic.has("headgear") and iDic.get("headgear") != null else null
equipment["armour"] = Item.new(iDic.get("armour")) if iDic.has("armour") and iDic.get("armour") != null else null equipment["armour"] = Item.new(iDic.get("armour")) if iDic.has("armour") and iDic.get("armour") != null else null
equipment["boots"] = Item.new(iDic.get("boots")) if iDic.has("boots") and iDic.get("boots") != null else null equipment["boots"] = Item.new(iDic.get("boots")) if iDic.has("boots") and iDic.get("boots") != null else null
equipment["accessory"] = Item.new(iDic.get("accessory")) if iDic.has("accessory") and iDic.get("accessory") != null else null equipment["accessory"] = Item.new(iDic.get("accessory")) if iDic.has("accessory") and iDic.get("accessory") != null else null
pass pass
@@ -383,7 +406,7 @@ func save() -> Dictionary:
"character_type": character_type, "character_type": character_type,
"character_name": character_name, "character_name": character_name,
"race": race, # Save race "race": race, # Save race
"baseStats": baseStats, "baseStats": baseStats,
"hp": hp, "hp": hp,
@@ -408,8 +431,8 @@ func save() -> Dictionary:
"facial_hair_color": facial_hair_color.to_html(true), "facial_hair_color": facial_hair_color.to_html(true),
"hair_color": hair_color.to_html(true), "hair_color": hair_color.to_html(true),
"eye_color": eye_color.to_html(true), # Save eye color "eye_color": eye_color.to_html(true), # Save eye color
"eyelash_color": eyelash_color.to_html(true), # Save eyelash color "eyelash_color": eyelash_color.to_html(true), # Save eyelash color
"inventory": saveInventory(), "inventory": saveInventory(),
"equipment": saveEquipment() "equipment": saveEquipment()
@@ -471,7 +494,7 @@ func load(iDic: Dictionary) -> void:
if iDic.has("eyes"): if iDic.has("eyes"):
eyes = iDic.get("eyes") eyes = iDic.get("eyes")
if iDic.has("eye_lashes"): if iDic.has("eye_lashes"):
eye_lashes = iDic.get("eye_lashes") eye_lashes = iDic.get("eye_lashes")
if iDic.has("add_on"): if iDic.has("add_on"):
add_on = iDic.get("add_on") add_on = iDic.get("add_on")
if iDic.has("facial_hair_color"): if iDic.has("facial_hair_color"):
@@ -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

@@ -6,34 +6,34 @@ extends StaticBody2D
#KeyDoors should always be closed at start #KeyDoors should always be closed at start
#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)
var open_offset: Vector2 = Vector2.ZERO # Offset from closed to open position (local) var open_offset: Vector2 = Vector2.ZERO # Offset from closed to open position (local)
# Room and puzzle state # Room and puzzle state
var blocking_room: Dictionary = {} # Room this door blocks (the room you enter INTO) var blocking_room: Dictionary = {} # Room this door blocks (the room you enter INTO)
var room1: Dictionary = {} # First room connected by this door (room you leave FROM) var room1: Dictionary = {} # First room connected by this door (room you leave FROM)
var room2: Dictionary = {} # Second room connected by this door (room you enter INTO - the blocking_room) var room2: Dictionary = {} # Second room connected by this door (room you enter INTO - the blocking_room)
var switch_room: Dictionary = {} # Room where the switch is located (before the door) var switch_room: Dictionary = {} # Room where the switch is located (before the door)
var room_trigger_area: Area2D = null # Reference to room trigger area for the blocking room var room_trigger_area: Area2D = null # Reference to room trigger area for the blocking room
var puzzle_solved: bool = false # True when room puzzle is solved var puzzle_solved: bool = false # True when room puzzle is solved
var enemies_defeated: bool = false # True when all enemies in room are defeated var enemies_defeated: bool = false # True when all enemies in room are defeated
var switches_activated: bool = false # True when required switches are activated var switches_activated: bool = false # True when required switches are activated
# Key door state # Key door state
var key_used: bool = false # True when key has been used var key_used: bool = false # True when key has been used
var key_indicator: Sprite2D = null # Visual indicator showing key above door var key_indicator: Sprite2D = null # Visual indicator showing key above door
# Floor switches this door is connected to # Floor switches this door is connected to
var connected_switches: Array = [] # Array of floor switch nodes var connected_switches: Array = [] # Array of floor switch nodes
var requires_enemies: bool = false # True if door requires defeating enemies to open var requires_enemies: bool = false # True if door requires defeating enemies to open
var requires_switch: bool = false # True if door requires activating switches to open var requires_switch: bool = false # True if door requires activating switches to open
# Smoke puff scene for StoneDoor effects # Smoke puff scene for StoneDoor effects
var smoke_puff_scene = preload("res://scenes/smoke_puff.tscn") var smoke_puff_scene = preload("res://scenes/smoke_puff.tscn")
@@ -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)
@@ -68,7 +68,7 @@ func _ready() -> void:
# So open_offset is positive Y (door moves down when closing, so open is up) # So open_offset is positive Y (door moves down when closing, so open is up)
# Actually wait - if closed is 16px down from open, then open is 16px up from closed # Actually wait - if closed is 16px down from open, then open is 16px up from closed
# So open_offset should be negative Y (open position is above closed position) # So open_offset should be negative Y (open position is above closed position)
open_offset = Vector2(0, -open_amount) # Open is 16px UP from closed open_offset = Vector2(0, -open_amount) # Open is 16px UP from closed
elif direction == "Down": elif direction == "Down":
# Door on bottom wall: # Door on bottom wall:
# For StoneDoor/GateDoor: open is at (col 1, row 1), closed is at (col 1, row 0) # For StoneDoor/GateDoor: open is at (col 1, row 1), closed is at (col 1, row 0)
@@ -76,19 +76,19 @@ func _ready() -> void:
# For KeyDoor: closed is at (col 1, row 0), open is at (col 1, row 1) # For KeyDoor: closed is at (col 1, row 0), open is at (col 1, row 1)
# So open is 16px DOWN from closed, open_offset = (0, 16) # So open is 16px DOWN from closed, open_offset = (0, 16)
# NOTE: This is recalculated in _ready_after_setup() based on door type # NOTE: This is recalculated in _ready_after_setup() based on door type
open_offset = Vector2(0, -open_amount) # For StoneDoor/GateDoor: open is 16px DOWN from closed open_offset = Vector2(0, -open_amount) # For StoneDoor/GateDoor: open is 16px DOWN from closed
elif direction == "Left": elif direction == "Left":
# Door on left wall: closed state is 16px RIGHT from open state # Door on left wall: closed state is 16px RIGHT from open state
# So open_offset is positive X (door moves right when closing, so open is left) # So open_offset is positive X (door moves right when closing, so open is left)
# Actually wait - if closed is 16px right from open, then open is 16px left from closed # Actually wait - if closed is 16px right from open, then open is 16px left from closed
# So open_offset should be negative X (open position is left of closed position) # So open_offset should be negative X (open position is left of closed position)
open_offset = Vector2(-open_amount, 0) # Open is 16px LEFT from closed open_offset = Vector2(-open_amount, 0) # Open is 16px LEFT from closed
elif direction == "Right": elif direction == "Right":
# Door on right wall: closed state is 16px LEFT from open state # Door on right wall: closed state is 16px LEFT from open state
# So open_offset is negative X (door moves left when closing, so open is right) # So open_offset is negative X (door moves left when closing, so open is right)
# Actually wait - if closed is 16px left from open, then open is 16px right from closed # Actually wait - if closed is 16px left from open, then open is 16px right from closed
# So open_offset should be positive X (open position is right of closed position) # So open_offset should be positive X (open position is right of closed position)
open_offset = Vector2(open_amount, 0) # Open is 16px RIGHT from closed open_offset = Vector2(open_amount, 0) # Open is 16px RIGHT from closed
# Note: closed_position will be set in _ready_after_setup after door is positioned # Note: closed_position will be set in _ready_after_setup after door is positioned
# For now, just initialize it # For now, just initialize it
@@ -169,9 +169,9 @@ func _process(delta: float) -> void:
var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else closed_position var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else closed_position
var target_pos = closed_position + open_offset var target_pos = closed_position + open_offset
position = start_pos.lerp(target_pos, progress) position = start_pos.lerp(target_pos, progress)
global_position = position # Also update global position during animation global_position = position # Also update global position during animation
# Debug: log for KeyDoors to verify movement # Debug: log for KeyDoors to verify movement
if type == "KeyDoor" and move_timer < 0.1: # Only log once at start of animation if type == "KeyDoor" and move_timer < 0.1: # Only log once at start of animation
LogManager.log("Door: KeyDoor opening animation - start: " + str(start_pos) + ", target: " + str(target_pos) + ", offset: " + str(open_offset) + ", direction: " + str(direction), LogManager.CATEGORY_DOOR) LogManager.log("Door: KeyDoor opening animation - start: " + str(start_pos) + ", target: " + str(target_pos) + ", offset: " + str(open_offset) + ", direction: " + str(direction), LogManager.CATEGORY_DOOR)
# For KeyDoors: disable collision as soon as opening starts (allow passage immediately) # For KeyDoors: disable collision as soon as opening starts (allow passage immediately)
@@ -198,7 +198,7 @@ func _process(delta: float) -> void:
# If animation_start_position wasn't set, calculate open position from closed_position + open_offset # If animation_start_position wasn't set, calculate open position from closed_position + open_offset
var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else (closed_position + open_offset) var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else (closed_position + open_offset)
position = start_pos.lerp(closed_position, progress) position = start_pos.lerp(closed_position, progress)
global_position = position # Also update global position during animation global_position = position # Also update global position during animation
# Update collision for StoneDoor/GateDoor only # Update collision for StoneDoor/GateDoor only
if type == "StoneDoor" or type == "GateDoor": if type == "StoneDoor" or type == "GateDoor":
@@ -220,7 +220,7 @@ func _process(delta: float) -> void:
# Move door to open position (away from closed position) # Move door to open position (away from closed position)
var open_position = closed_position + open_offset var open_position = closed_position + open_offset
position = open_position position = open_position
global_position = open_position # Also set global position global_position = open_position # Also set global position
# When moved from closed position (open), collision should be DISABLED # When moved from closed position (open), collision should be DISABLED
set_collision_layer_value(7, false) set_collision_layer_value(7, false)
var key_used_str = " (key_used=" + str(key_used) + ")" if type == "KeyDoor" else "" var key_used_str = " (key_used=" + str(key_used) + ")" if type == "KeyDoor" else ""
@@ -239,12 +239,12 @@ func _process(delta: float) -> void:
is_opening = false is_opening = false
is_closing = false is_closing = false
move_timer = 0.0 move_timer = 0.0
animation_start_position = Vector2.ZERO # Reset animation start position animation_start_position = Vector2.ZERO # Reset animation start position
else: else:
# Closing animation complete # Closing animation complete
is_closed = true is_closed = true
position = closed_position position = closed_position
global_position = closed_position # Also set global position global_position = closed_position # Also set global position
# When at closed position, collision should be ENABLED # When at closed position, collision should be ENABLED
set_collision_layer_value(7, true) set_collision_layer_value(7, true)
LogManager.log("Door: Closing animation complete - moved to closed position: " + str(closed_position) + " - collision ENABLED", LogManager.CATEGORY_DOOR) LogManager.log("Door: Closing animation complete - moved to closed position: " + str(closed_position) + " - collision ENABLED", LogManager.CATEGORY_DOOR)
@@ -257,7 +257,7 @@ func _process(delta: float) -> void:
is_opening = false is_opening = false
is_closing = false is_closing = false
move_timer = 0.0 move_timer = 0.0
animation_start_position = Vector2.ZERO # Reset animation start position animation_start_position = Vector2.ZERO # Reset animation start position
# Now that door has finished closing, check if puzzle is solved (to open it immediately if already solved) # Now that door has finished closing, check if puzzle is solved (to open it immediately if already solved)
if type == "StoneDoor" or type == "GateDoor": if type == "StoneDoor" or type == "GateDoor":
_check_puzzle_state() _check_puzzle_state()
@@ -312,11 +312,11 @@ func _update_collision_based_on_position():
# CRITICAL: This function ONLY updates collision, it does NOT change position or is_closed flag # CRITICAL: This function ONLY updates collision, it does NOT change position or is_closed flag
# Position and is_closed should only be changed by explicit _open()/_close() calls or animation # Position and is_closed should only be changed by explicit _open()/_close() calls or animation
if type == "KeyDoor": if type == "KeyDoor":
return # Don't update KeyDoors - they handle their own state return # Don't update KeyDoors - they handle their own state
# Only update collision, don't change position or is_closed flag # Only update collision, don't change position or is_closed flag
var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0
var distance_threshold = 1.0 # Consider "at closed position" if within 1 pixel var distance_threshold = 1.0 # Consider "at closed position" if within 1 pixel
if distance_to_closed <= distance_threshold: if distance_to_closed <= distance_threshold:
# Door is at closed position - collision should be ENABLED # Door is at closed position - collision should be ENABLED
@@ -330,7 +330,7 @@ func _update_collision_based_on_position():
func _open(): func _open():
# Only open on server/authority in multiplayer, then sync to clients # Only open on server/authority in multiplayer, then sync to clients
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
return # Clients wait for RPC return # Clients wait for RPC
$TeleporterIntoClosedRoom.is_enabled = false $TeleporterIntoClosedRoom.is_enabled = false
# CRITICAL: For KeyDoors, ensure they start from closed position before opening # CRITICAL: For KeyDoors, ensure they start from closed position before opening
@@ -343,7 +343,7 @@ func _open():
position = closed_position position = closed_position
global_position = closed_position global_position = closed_position
is_closed = true is_closed = true
set_collision_layer_value(7, true) # Collision enabled at closed position set_collision_layer_value(7, true) # Collision enabled at closed position
LogManager.log("Door: KeyDoor _open() called - reset to closed position " + str(closed_position) + " before opening", LogManager.CATEGORY_DOOR) LogManager.log("Door: KeyDoor _open() called - reset to closed position " + str(closed_position) + " before opening", LogManager.CATEGORY_DOOR)
else: else:
LogManager.log_error("Door: KeyDoor _open() called but closed_position is zero!", LogManager.CATEGORY_DOOR) LogManager.log_error("Door: KeyDoor _open() called but closed_position is zero!", LogManager.CATEGORY_DOOR)
@@ -363,7 +363,7 @@ func _open():
global_position = open_pos global_position = open_pos
is_closed = false is_closed = false
set_collision_layer_value(7, false) set_collision_layer_value(7, false)
return # Don't start animation return # Don't start animation
# Door is closed - ensure it's at closed position before opening # Door is closed - ensure it's at closed position before opening
if closed_position != Vector2.ZERO: if closed_position != Vector2.ZERO:
@@ -409,7 +409,7 @@ func _open():
func _close(): func _close():
# Only close on server/authority in multiplayer, then sync to clients # Only close on server/authority in multiplayer, then sync to clients
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
return # Clients wait for RPC return # Clients wait for RPC
# CRITICAL: KeyDoors should NEVER be closed (they only open with a key and stay open) # CRITICAL: KeyDoors should NEVER be closed (they only open with a key and stay open)
if type == "KeyDoor": if type == "KeyDoor":
@@ -424,23 +424,23 @@ func _close():
# Check both flag and actual position to determine door state # Check both flag and actual position to determine door state
var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0
var is_actually_at_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position var is_actually_at_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position
LogManager.log("Door: _close() called - is_closed: " + str(is_closed) + ", is_actually_at_closed: " + str(is_actually_at_closed) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) LogManager.log("Door: _close() called - is_closed: " + str(is_closed) + ", is_actually_at_closed: " + str(is_actually_at_closed) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR)
# If door is already at closed position (both visually and by flag), don't do anything # If door is already at closed position (both visually and by flag), don't do anything
if is_closed and is_actually_at_closed and not is_opening and not is_closing: if is_closed and is_actually_at_closed and not is_opening and not is_closing:
LogManager.log("Door: Already closed (both flag and position match), not closing again", LogManager.CATEGORY_DOOR) LogManager.log("Door: Already closed (both flag and position match), not closing again", LogManager.CATEGORY_DOOR)
return # Already closed, don't do anything return # Already closed, don't do anything
# CRITICAL: If door is at closed position but flag says open, just fix the state - don't animate # CRITICAL: If door is at closed position but flag says open, just fix the state - don't animate
if is_actually_at_closed and not is_closed: if is_actually_at_closed and not is_closed:
# Door is visually at closed position but flag says open - fix state only # Door is visually at closed position but flag says open - fix state only
LogManager.log("Door: Door is at closed position but flag says open! Fixing state only (no animation)", LogManager.CATEGORY_DOOR) LogManager.log("Door: Door is at closed position but flag says open! Fixing state only (no animation)", LogManager.CATEGORY_DOOR)
position = closed_position # Ensure exact position position = closed_position # Ensure exact position
is_closed = true is_closed = true
set_collision_layer_value(7, true) set_collision_layer_value(7, true)
return # Don't start animation return # Don't start animation
# Door is actually open (position is away from closed position) - start closing animation # Door is actually open (position is away from closed position) - start closing animation
# CRITICAL: Store starting position BEFORE starting animation # CRITICAL: Store starting position BEFORE starting animation
@@ -493,7 +493,7 @@ func _ready_after_setup():
# Called after door is fully set up with room references and positioned # Called after door is fully set up with room references and positioned
# NEW LOGIC: Door is positioned at OPEN tile position by game_world # NEW LOGIC: Door is positioned at OPEN tile position by game_world
# The position set by game_world is the OPEN position (initial state for blocking doors) # The position set by game_world is the OPEN position (initial state for blocking doors)
var open_position = position # Current position is the OPEN position (from tile coordinates) var open_position = position # Current position is the OPEN position (from tile coordinates)
LogManager.log("Door: _ready_after_setup() called - type: " + str(type) + ", direction: " + str(direction) + ", is_closed: " + str(is_closed) + ", open_position: " + str(open_position), LogManager.CATEGORY_DOOR) LogManager.log("Door: _ready_after_setup() called - type: " + str(type) + ", direction: " + str(direction) + ", is_closed: " + str(is_closed) + ", open_position: " + str(open_position), LogManager.CATEGORY_DOOR)
@@ -508,7 +508,7 @@ func _ready_after_setup():
var closed_offset = Vector2.ZERO var closed_offset = Vector2.ZERO
match direction: match direction:
"Up": "Up":
closed_offset = Vector2(0, 16) # Closed is 16px DOWN from open closed_offset = Vector2(0, 16) # Closed is 16px DOWN from open
"Down": "Down":
# CRITICAL: For StoneDoor/GateDoor, they start OPEN at (col 1, row 1) and close to (col 1, row 0) # CRITICAL: For StoneDoor/GateDoor, they start OPEN at (col 1, row 1) and close to (col 1, row 0)
# So closed is 16px UP from open (negative Y) # So closed is 16px UP from open (negative Y)
@@ -517,20 +517,20 @@ func _ready_after_setup():
if type == "KeyDoor": if type == "KeyDoor":
# KeyDoor: closed is at row 0, open is at row 1 (16px down) # KeyDoor: closed is at row 0, open is at row 1 (16px down)
# But we calculate from open_position, so this won't be used for KeyDoor # But we calculate from open_position, so this won't be used for KeyDoor
closed_offset = Vector2(0, -16) # Won't be used - KeyDoor uses different logic closed_offset = Vector2(0, -16) # Won't be used - KeyDoor uses different logic
else: else:
# StoneDoor/GateDoor: open is at row 1, closed is at row 0 (16px up) # StoneDoor/GateDoor: open is at row 1, closed is at row 0 (16px up)
closed_offset = Vector2(0, -16) # Closed is 16px UP from open closed_offset = Vector2(0, -16) # Closed is 16px UP from open
"Left": "Left":
closed_offset = Vector2(16, 0) # Closed is 16px RIGHT from open closed_offset = Vector2(16, 0) # Closed is 16px RIGHT from open
"Right": "Right":
closed_offset = Vector2(-16, 0) # Closed is 16px LEFT from open closed_offset = Vector2(-16, 0) # Closed is 16px LEFT from open
closed_position = open_position + closed_offset closed_position = open_position + closed_offset
# 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)
@@ -541,38 +541,38 @@ func _ready_after_setup():
if type == "KeyDoor": if type == "KeyDoor":
# For KeyDoors, the position from game_world is the CLOSED position # For KeyDoors, the position from game_world is the CLOSED position
# Calculate open position from closed position # Calculate open position from closed position
var keydoor_closed_position = position # Current position is CLOSED (from game_world) var keydoor_closed_position = position # Current position is CLOSED (from game_world)
# Calculate open position based on direction # Calculate open position based on direction
var keydoor_open_offset = Vector2.ZERO var keydoor_open_offset = Vector2.ZERO
match direction: match direction:
"Up": "Up":
keydoor_open_offset = Vector2(0, -16) # Open is 16px UP from closed keydoor_open_offset = Vector2(0, -16) # Open is 16px UP from closed
"Down": "Down":
keydoor_open_offset = Vector2(0, 16) # Open is 16px DOWN from closed (row 0 to row 1) keydoor_open_offset = Vector2(0, 16) # Open is 16px DOWN from closed (row 0 to row 1)
"Left": "Left":
keydoor_open_offset = Vector2(-16, 0) # Open is 16px LEFT from closed keydoor_open_offset = Vector2(-16, 0) # Open is 16px LEFT from closed
"Right": "Right":
keydoor_open_offset = Vector2(16, 0) # Open is 16px RIGHT from closed keydoor_open_offset = Vector2(16, 0) # Open is 16px RIGHT from closed
# Set positions correctly for KeyDoor # Set positions correctly for KeyDoor
closed_position = keydoor_closed_position # Closed is where game_world placed it closed_position = keydoor_closed_position # Closed is where game_world placed it
open_offset = keydoor_open_offset # Offset to move from closed to open open_offset = keydoor_open_offset # Offset to move from closed to open
# KeyDoor starts CLOSED # KeyDoor starts CLOSED
is_closed = true is_closed = true
position = closed_position position = closed_position
global_position = closed_position global_position = closed_position
set_collision_layer_value(7, true) # Collision enabled when closed set_collision_layer_value(7, true) # Collision enabled when closed
LogManager.log("Door: KeyDoor starting CLOSED at position " + str(position) + " (direction: " + str(direction) + "), will open to " + str(closed_position + open_offset) + " - collision ENABLED", LogManager.CATEGORY_DOOR) LogManager.log("Door: KeyDoor starting CLOSED at position " + str(position) + " (direction: " + str(direction) + "), will open to " + str(closed_position + open_offset) + " - collision ENABLED", LogManager.CATEGORY_DOOR)
# Create key indicator sprite for KeyDoor # Create key indicator sprite for KeyDoor
_create_key_indicator() _create_key_indicator()
return # Exit early for KeyDoors return # Exit early for KeyDoors
elif is_closed: elif is_closed:
# StoneDoor/GateDoor starting closed (shouldn't happen for blocking doors, but handle it) # StoneDoor/GateDoor starting closed (shouldn't happen for blocking doors, but handle it)
position = closed_position position = closed_position
global_position = closed_position global_position = closed_position
is_closed = true # Ensure state matches position is_closed = true # Ensure state matches position
set_collision_layer_value(7, true) set_collision_layer_value(7, true)
LogManager.log("Door: Starting CLOSED at position " + str(position) + " (type: " + str(type) + ", direction: " + str(direction) + ") - collision ENABLED", LogManager.CATEGORY_DOOR) LogManager.log("Door: Starting CLOSED at position " + str(position) + " (type: " + str(type) + ", direction: " + str(direction) + ") - collision ENABLED", LogManager.CATEGORY_DOOR)
else: else:
@@ -584,26 +584,26 @@ func _ready_after_setup():
LogManager.log("Door: WARNING - Position doesn't match open_position! Forcing to open: " + str(open_position) + " (was: " + str(position) + ")", LogManager.CATEGORY_DOOR) LogManager.log("Door: WARNING - Position doesn't match open_position! Forcing to open: " + str(open_position) + " (was: " + str(position) + ")", LogManager.CATEGORY_DOOR)
position = open_position position = open_position
global_position = position # Ensure global_position matches position global_position = position # Ensure global_position matches position
is_closed = false # CRITICAL: State MUST be false (open) when at open position is_closed = false # CRITICAL: State MUST be false (open) when at open position
set_collision_layer_value(7, false) # CRITICAL: Collision MUST be DISABLED when open set_collision_layer_value(7, false) # CRITICAL: Collision MUST be DISABLED when open
LogManager.log("Door: Starting OPEN at position " + str(position) + " (closed: " + str(closed_position) + ", open: " + str(open_position) + ", open_offset: " + str(open_offset) + ", type: " + str(type) + ", direction: " + str(direction) + ") - collision DISABLED, is_closed: " + str(is_closed), LogManager.CATEGORY_DOOR) LogManager.log("Door: Starting OPEN at position " + str(position) + " (closed: " + str(closed_position) + ", open: " + str(open_position) + ", open_offset: " + str(open_offset) + ", type: " + str(type) + ", direction: " + str(direction) + ") - collision DISABLED, is_closed: " + str(is_closed), LogManager.CATEGORY_DOOR)
# CRITICAL: Verify the door is actually at open position after setting it # CRITICAL: Verify the door is actually at open position after setting it
var actual_distance = position.distance_to(closed_position) var actual_distance = position.distance_to(closed_position)
var expected_distance = 16.0 # Should be 16 pixels away var expected_distance = 16.0 # Should be 16 pixels away
if abs(actual_distance - expected_distance) > 2.0: if abs(actual_distance - expected_distance) > 2.0:
LogManager.log_error("Door: ERROR - Door open/closed distance is wrong! Position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(actual_distance) + " (expected: " + str(expected_distance) + ")", LogManager.CATEGORY_DOOR) LogManager.log_error("Door: ERROR - Door open/closed distance is wrong! Position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(actual_distance) + " (expected: " + str(expected_distance) + ")", LogManager.CATEGORY_DOOR)
# Force it to correct open position # Force it to correct open position
position = open_position position = open_position
global_position = open_position global_position = open_position
is_closed = false # CRITICAL: Ensure state is false when at open position is_closed = false # CRITICAL: Ensure state is false when at open position
set_collision_layer_value(7, false) set_collision_layer_value(7, false)
LogManager.log("Door: FORCED door to open position: " + str(position) + " (distance to closed: " + str(position.distance_to(closed_position)) + ", is_closed: " + str(is_closed) + ")", LogManager.CATEGORY_DOOR) LogManager.log("Door: FORCED door to open position: " + str(position) + " (distance to closed: " + str(position.distance_to(closed_position)) + ", is_closed: " + str(is_closed) + ")", LogManager.CATEGORY_DOOR)
# FINAL VERIFICATION: Double-check state matches position # FINAL VERIFICATION: Double-check state matches position
var distance_to_closed = position.distance_to(closed_position) var distance_to_closed = position.distance_to(closed_position)
var should_be_open = distance_to_closed > 8.0 # If more than 8px from closed, should be open var should_be_open = distance_to_closed > 8.0 # If more than 8px from closed, should be open
if should_be_open and is_closed: if should_be_open and is_closed:
LogManager.log_error("Door: ERROR - Door is at open position but is_closed is true! Fixing state...", LogManager.CATEGORY_DOOR) LogManager.log_error("Door: ERROR - Door is at open position but is_closed is true! Fixing state...", LogManager.CATEGORY_DOOR)
is_closed = false is_closed = false
@@ -624,7 +624,7 @@ func _ready_after_setup():
func _create_key_indicator(): func _create_key_indicator():
# Create visual indicator for key above door # Create visual indicator for key above door
if key_indicator: if key_indicator:
return # Already created return # Already created
key_indicator = Sprite2D.new() key_indicator = Sprite2D.new()
# Load key texture from loot system # Load key texture from loot system
@@ -633,9 +633,9 @@ func _create_key_indicator():
key_indicator.texture = key_texture key_indicator.texture = key_texture
key_indicator.hframes = 20 key_indicator.hframes = 20
key_indicator.vframes = 14 key_indicator.vframes = 14
key_indicator.frame = (13 * 20) + 10 # Key frame from loot system key_indicator.frame = (13 * 20) + 10 # Key frame from loot system
key_indicator.position = Vector2(0, -24) # Above door key_indicator.position = Vector2(0, -24) # Above door
key_indicator.visible = false # Hidden until key is used key_indicator.visible = false # Hidden until key is used
add_child(key_indicator) add_child(key_indicator)
func _on_room_entered(body): func _on_room_entered(body):
@@ -646,7 +646,7 @@ func _on_room_entered(body):
# Verify this door is in the room we just entered # Verify this door is in the room we just entered
if not room_trigger_area: if not room_trigger_area:
return # No trigger set, don't do anything return # No trigger set, don't do anything
var trigger_room = room_trigger_area.room if room_trigger_area.room else {} var trigger_room = room_trigger_area.room if room_trigger_area.room else {}
var door_room1 = room1 if room1 else {} var door_room1 = room1 if room1 else {}
@@ -675,7 +675,7 @@ func _on_room_entered(body):
if not puzzle_solved: if not puzzle_solved:
# Check both is_closed flag AND actual position to determine door state # Check both is_closed flag AND actual position to determine door state
var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0
var is_actually_open = distance_to_closed > 5.0 # If door is more than 5 pixels away from closed_position, it's open var is_actually_open = distance_to_closed > 5.0 # If door is more than 5 pixels away from closed_position, it's open
LogManager.log("Door: _on_room_entered() - type: " + str(type) + ", is_closed: " + str(is_closed) + ", is_actually_open: " + str(is_actually_open) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) LogManager.log("Door: _on_room_entered() - type: " + str(type) + ", is_closed: " + str(is_closed) + ", is_actually_open: " + str(is_actually_open) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR)
@@ -699,7 +699,7 @@ func _on_room_entered(body):
_close() _close()
# Don't check puzzle state immediately - wait for door to finish closing # Don't check puzzle state immediately - wait for door to finish closing
# Puzzle state will be checked when closing animation completes (in _process) # Puzzle state will be checked when closing animation completes (in _process)
return # Exit early, don't check puzzle state yet return # Exit early, don't check puzzle state yet
elif is_actually_open: elif is_actually_open:
# Door is open but animation already in progress - don't interfere # Door is open but animation already in progress - don't interfere
LogManager.log("Door: Door is open but animation in progress, not closing", LogManager.CATEGORY_DOOR) LogManager.log("Door: Door is open but animation in progress, not closing", LogManager.CATEGORY_DOOR)
@@ -714,7 +714,7 @@ func _on_room_entered(body):
position = closed_position position = closed_position
global_position = closed_position global_position = closed_position
is_closed = true is_closed = true
set_collision_layer_value(7, true) # Collision ENABLED when closed set_collision_layer_value(7, true) # Collision ENABLED when closed
LogManager.log("Door: Door was already closed - ensuring state is correct, position: " + str(position) + ", closed: " + str(closed_position), LogManager.CATEGORY_DOOR) LogManager.log("Door: Door was already closed - ensuring state is correct, position: " + str(position) + ", closed: " + str(closed_position), LogManager.CATEGORY_DOOR)
# Now that door is confirmed closed, check if puzzle is already solved # Now that door is confirmed closed, check if puzzle is already solved
# CRITICAL: Only check puzzle state if door is closed - don't check if puzzle is already solved # CRITICAL: Only check puzzle state if door is closed - don't check if puzzle is already solved
@@ -732,7 +732,7 @@ func _on_room_exited(body):
func _check_puzzle_state(): func _check_puzzle_state():
# Only check puzzle state on server/authority in multiplayer # Only check puzzle state on server/authority in multiplayer
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
return # Clients wait for server to check and sync via RPC return # Clients wait for server to check and sync via RPC
# CRITICAL: Don't check puzzle state while door is animating (closing or opening) # CRITICAL: Don't check puzzle state while door is animating (closing or opening)
# This prevents race conditions where switch triggers before door finishes closing # This prevents race conditions where switch triggers before door finishes closing
@@ -742,9 +742,9 @@ func _check_puzzle_state():
# Check door's actual state (position-based check is more reliable than flags) # Check door's actual state (position-based check is more reliable than flags)
var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0
var is_actually_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position var is_actually_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position
var is_actually_open = distance_to_closed > 5.0 # More than 5 pixels away from closed position var is_actually_open = distance_to_closed > 5.0 # More than 5 pixels away from closed position
var collision_enabled = get_collision_layer_value(7) # Check if collision layer 7 is enabled var collision_enabled = get_collision_layer_value(7) # Check if collision layer 7 is enabled
# CRITICAL: If puzzle_solved is true but door is not actually open (not in open position or collision still enabled), # CRITICAL: If puzzle_solved is true but door is not actually open (not in open position or collision still enabled),
# allow switch to trigger again to open the door # allow switch to trigger again to open the 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
@@ -829,7 +857,7 @@ func _are_all_enemies_defeated() -> bool:
if child.is_in_group("enemy"): if child.is_in_group("enemy"):
# CRITICAL: Only check enemies that were spawned from spawners (not pre-spawned) # CRITICAL: Only check enemies that were spawned from spawners (not pre-spawned)
if not child.has_meta("spawned_from_spawner") or not child.get_meta("spawned_from_spawner"): if not child.has_meta("spawned_from_spawner") or not child.get_meta("spawned_from_spawner"):
continue # Skip pre-spawned enemies continue # Skip pre-spawned enemies
# Check if enemy is in this room (use position-based check, more reliable) # Check if enemy is in this room (use position-based check, more reliable)
var enemy_in_room = false var enemy_in_room = false
@@ -849,7 +877,7 @@ func _are_all_enemies_defeated() -> bool:
var spawner_name = child.get_meta("spawner_name") var spawner_name = child.get_meta("spawner_name")
# Spawner names are like "EnemySpawner_<room_x>_<room_y>" # Spawner names are like "EnemySpawner_<room_x>_<room_y>"
if str(target_room.x) in spawner_name and str(target_room.y) in spawner_name: if str(target_room.x) in spawner_name and str(target_room.y) in spawner_name:
enemy_in_room = true # Confirmed by spawner name enemy_in_room = true # Confirmed by spawner name
if enemy_in_room: if enemy_in_room:
room_spawned_enemies.append(child) room_spawned_enemies.append(child)
@@ -874,12 +902,12 @@ func _are_all_enemies_defeated() -> bool:
if not enemy_is_dead: if not enemy_is_dead:
LogManager.log("Door: Enemy " + str(enemy.name) + " is still alive - puzzle not solved yet", LogManager.CATEGORY_DOOR) LogManager.log("Door: Enemy " + str(enemy.name) + " is still alive - puzzle not solved yet", LogManager.CATEGORY_DOOR)
return false # Enemy is still alive, puzzle not solved return false # Enemy is still alive, puzzle not solved
# If we have enemies and all are dead, puzzle is solved # If we have enemies and all are dead, puzzle is solved
if room_spawned_enemies.size() > 0: if room_spawned_enemies.size() > 0:
LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR)
return true # All enemies found are dead return true # All enemies found are dead
# 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
@@ -913,7 +941,7 @@ func _are_all_enemies_defeated() -> bool:
enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y) enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y)
if not enemy_in_room: if not enemy_in_room:
continue # Skip enemies not in this room continue # Skip enemies not in this room
# Check if enemy is alive # Check if enemy is alive
var enemy_is_alive = false var enemy_is_alive = false
@@ -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 = []
@@ -1026,7 +1053,7 @@ func _are_all_enemies_defeated() -> bool:
enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y) enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y)
if not enemy_in_room: if not enemy_in_room:
continue # Skip enemies not in this room continue # Skip enemies not in this room
# Check if enemy is dead # Check if enemy is dead
var enemy_is_dead = false var enemy_is_dead = false
@@ -1053,8 +1080,8 @@ func _are_all_enemies_defeated() -> bool:
# Found dead enemies with matching spawner names - spawners definitely spawned and enemies are dead # Found dead enemies with matching spawner names - spawners definitely spawned and enemies are dead
if spawners_in_room.size() == 0: if spawners_in_room.size() == 0:
for spawner_name in unique_spawner_names_that_spawned.keys(): for spawner_name in unique_spawner_names_that_spawned.keys():
spawners_in_room.append(null) # Placeholder for destroyed spawner spawners_in_room.append(null) # Placeholder for destroyed spawner
spawners_that_have_spawned.append(null) # Count as spawned spawners_that_have_spawned.append(null) # Count as spawned
LogManager.log("Door: Spawner " + str(spawner_name) + " was destroyed but spawned enemies that are now all dead - counting as spawned", LogManager.CATEGORY_DOOR) LogManager.log("Door: Spawner " + str(spawner_name) + " was destroyed but spawned enemies that are now all dead - counting as spawned", LogManager.CATEGORY_DOOR)
else: else:
# Spawners exist - check if any weren't counted as spawned yet # Spawners exist - check if any weren't counted as spawned yet
@@ -1122,14 +1149,14 @@ func _are_all_enemies_defeated() -> bool:
LogManager.log("Door: Enemy is no longer valid (removed from scene) - counting as dead", LogManager.CATEGORY_DOOR) LogManager.log("Door: Enemy is no longer valid (removed from scene) - counting as dead", LogManager.CATEGORY_DOOR)
LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR)
return true # All enemies are dead return true # All enemies are dead
func _spawn_smoke_puffs_on_close(): func _spawn_smoke_puffs_on_close():
# Spawn 1-3 smoke puffs when StoneDoor finishes closing # Spawn 1-3 smoke puffs when StoneDoor finishes closing
if not smoke_puff_scene: if not smoke_puff_scene:
return return
var puff_count = randi_range(1, 3) # Random between 1-3 puffs var puff_count = randi_range(1, 3) # Random between 1-3 puffs
for i in range(puff_count): for i in range(puff_count):
var puff = smoke_puff_scene.instantiate() var puff = smoke_puff_scene.instantiate()
@@ -1138,7 +1165,7 @@ func _spawn_smoke_puffs_on_close():
var offset_x = randf_range(-8, 8) var offset_x = randf_range(-8, 8)
var offset_y = randf_range(-8, 8) var offset_y = randf_range(-8, 8)
puff.global_position = global_position + Vector2(offset_x, offset_y) puff.global_position = global_position + Vector2(offset_x, offset_y)
puff.z_index = 10 # High z-index to ensure visibility puff.z_index = 10 # High z-index to ensure visibility
# Add to Entities node for proper layering # Add to Entities node for proper layering
var entities_node = get_tree().get_first_node_in_group("game_world") var entities_node = get_tree().get_first_node_in_group("game_world")
@@ -1158,7 +1185,7 @@ func _spawn_smoke_puffs_on_open():
if not smoke_puff_scene: if not smoke_puff_scene:
return return
var puff_count = randi_range(1, 2) # Random between 1-2 puffs var puff_count = randi_range(1, 2) # Random between 1-2 puffs
for i in range(puff_count): for i in range(puff_count):
var puff = smoke_puff_scene.instantiate() var puff = smoke_puff_scene.instantiate()
@@ -1167,7 +1194,7 @@ func _spawn_smoke_puffs_on_open():
var offset_x = randf_range(-8, 8) var offset_x = randf_range(-8, 8)
var offset_y = randf_range(-8, 8) var offset_y = randf_range(-8, 8)
puff.global_position = global_position + Vector2(offset_x, offset_y) puff.global_position = global_position + Vector2(offset_x, offset_y)
puff.z_index = 10 # High z-index to ensure visibility puff.z_index = 10 # High z-index to ensure visibility
# Add to Entities node for proper layering # Add to Entities node for proper layering
var entities_node = get_tree().get_first_node_in_group("game_world") var entities_node = get_tree().get_first_node_in_group("game_world")
@@ -1199,12 +1226,12 @@ func _are_all_switches_activated() -> bool:
LogManager.log("Door: Switch " + str(switch.name) + " is NOT activated", LogManager.CATEGORY_DOOR) LogManager.log("Door: Switch " + str(switch.name) + " is NOT activated", LogManager.CATEGORY_DOOR)
return false return false
LogManager.log("Door: All connected switches are activated!", LogManager.CATEGORY_DOOR) LogManager.log("Door: All connected switches are activated!", LogManager.CATEGORY_DOOR)
return true # All connected switches are activated return true # All connected switches are activated
# CRITICAL: If no switches are connected, the puzzle is NOT solved! # CRITICAL: If no switches are connected, the puzzle is NOT solved!
# Switches should ALWAYS be connected when spawned - if they're not, it's an error # Switches should ALWAYS be connected when spawned - if they're not, it's an error
LogManager.log("Door: WARNING - Door " + str(name) + " has no connected switches! Puzzle cannot be solved!", LogManager.CATEGORY_DOOR) LogManager.log("Door: WARNING - Door " + str(name) + " has no connected switches! Puzzle cannot be solved!", LogManager.CATEGORY_DOOR)
return false # No connected switches means puzzle is NOT solved return false # No connected switches means puzzle is NOT solved
func _on_key_interaction_area_body_entered(body): func _on_key_interaction_area_body_entered(body):
# Player entered key interaction area # Player entered key interaction area
@@ -1228,7 +1255,7 @@ func _show_key_indicator():
key_indicator.visible = true key_indicator.visible = true
# Make sure it's on top (higher z-index or add to front) # Make sure it's on top (higher z-index or add to front)
key_indicator.z_index = 10 key_indicator.z_index = 10
move_child(key_indicator, get_child_count() - 1) # Move to front move_child(key_indicator, get_child_count() - 1) # Move to front
else: else:
# Create key indicator if it doesn't exist yet # Create key indicator if it doesn't exist yet
_create_key_indicator() _create_key_indicator()
@@ -1244,13 +1271,13 @@ func teleportPlayer(body: Node2D):
var keydoor_open_offset = Vector2.ZERO var keydoor_open_offset = Vector2.ZERO
match direction: match direction:
"Up": "Up":
keydoor_open_offset = Vector2(0, 16) # Open is 16px UP from closed keydoor_open_offset = Vector2(0, 16) # Open is 16px UP from closed
"Down": "Down":
keydoor_open_offset = Vector2(0, -16) # Open is 16px DOWN from closed (row 0 to row 1) keydoor_open_offset = Vector2(0, -16) # Open is 16px DOWN from closed (row 0 to row 1)
"Left": "Left":
keydoor_open_offset = Vector2(16, 0) # Open is 16px LEFT from closed keydoor_open_offset = Vector2(16, 0) # Open is 16px LEFT from closed
"Right": "Right":
keydoor_open_offset = Vector2(-16, 0) # Open is 16px RIGHT from closed keydoor_open_offset = Vector2(-16, 0) # Open is 16px RIGHT from closed
var new_position = self.global_position + keydoor_open_offset var new_position = self.global_position + keydoor_open_offset
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -54,10 +54,19 @@ 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
current_health = max_health 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
add_to_group("enemy") add_to_group("enemy")
# Setup shadow (if it exists - some enemies like humanoids don't have a Shadow node) # Setup shadow (if it exists - some enemies like humanoids don't have a Shadow node)
@@ -72,6 +81,19 @@ func _ready():
# This allows enemies to collide with interactable objects so they can path around them # This allows enemies to collide with interactable objects so they can path around them
# 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
@@ -127,70 +149,9 @@ 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 return
if fallout_defeat_started:
velocity = Vector2.ZERO
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
if fallout_scale_progress <= 0.0:
died_from_fallout = true
call_deferred("_die")
return
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
rotation = deg_to_rad(45.0)
move_and_slide()
return
# Only ground enemies (position_z <= 0) can fall into fallout; bat has position_z 1 and ignores it
# Humanoid: use 16x16 box check (like player) so any part on fallout triggers; drag toward center then sink
var gw = get_tree().get_first_node_in_group("game_world")
var on_fallout = false
if position_z <= 0.0 and gw:
if "humanoid_type" in self and gw.has_method("_is_player_box_on_fallout_tile"):
on_fallout = gw._is_player_box_on_fallout_tile(global_position, 8.0)
elif gw.has_method("_is_position_on_fallout_tile"):
on_fallout = gw._is_position_on_fallout_tile(global_position)
if on_fallout:
if "humanoid_type" in self:
# Humanoid: drag toward tile center (quicksand pull) then sink when at center
var tile_center = gw._get_closest_fallout_tile_center(global_position) if gw.has_method("_get_closest_fallout_tile_center") else (gw._get_tile_center_at(global_position) if gw.has_method("_get_tile_center_at") else global_position)
var dist_to_center = global_position.distance_to(tile_center)
if dist_to_center < FALLOUT_CENTER_THRESHOLD:
global_position = tile_center
fallout_state = true
fallout_scale_progress = 1.0
velocity = Vector2.ZERO
if has_method("_set_animation"):
_set_animation("FALL")
if has_node("SfxFallout"):
$SfxFallout.play()
else:
# Drag toward center (quicksand pull)
var dir = (tile_center - global_position).normalized()
const FALLOUT_DRAG_STRENGTH: float = 820.0
velocity = dir * FALLOUT_DRAG_STRENGTH * get_process_delta_time()
if has_method("_set_animation"):
_set_animation("RUN")
move_and_slide()
return
else:
# Slime-like: rotate 45 and scale down then die
fallout_defeat_started = true
fallout_scale_progress = 1.0
rotation = deg_to_rad(45.0)
move_and_slide()
return
# Update attack timer # Update attack timer
if attack_timer > 0: if attack_timer > 0:
@@ -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)
var state_val = -1 # Call this from base _physics_process or from subclasses that override _physics_process (e.g. enemy_spider) so position sync is always sent.
if "state" in self: func _send_position_sync_to_clients() -> void:
state_val = get("state") as int if not multiplayer.has_multiplayer_peer() or not is_multiplayer_authority():
# Only send RPC if we're in the scene tree return
if is_inside_tree(): var state_val = -1
# Get enemy name/index for identification if "state" in self:
var enemy_name = name state_val = get("state") as int
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 if not is_inside_tree():
return
# Use game_world to send RPC instead of rpc() on node instance var enemy_name = name
# This avoids node path resolution issues when clients haven't spawned yet var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
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)
game_world._rpc_to_ready_peers("_sync_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"):
# Removed fallback rpc() call - it causes node path resolution errors game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val])
# 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

File diff suppressed because it is too large Load Diff

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
@@ -72,6 +79,94 @@ func _ready():
if shadow: if shadow:
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
@@ -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,69 +973,81 @@ 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"):
# Add item to player inventory if chest_item:
if by_player.character_stats: # Add item to player inventory
var was_encumbered = by_player.character_stats.is_over_encumbered() if by_player.character_stats:
by_player.character_stats.add_item(chest_item) var was_encumbered = by_player.character_stats.is_over_encumbered()
if not was_encumbered and by_player.character_stats.is_over_encumbered(): by_player.character_stats.add_item(chest_item)
if by_player.has_method("show_floating_status"): if not was_encumbered and by_player.character_stats.is_over_encumbered():
by_player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) if by_player.has_method("show_floating_status"):
by_player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2))
# Show pickup notification
var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null # Show pickup notification
var display_text = chest_item.item_name.to_upper() var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null
var item_color = Color.WHITE var display_text = chest_item.item_name.to_upper()
var item_color = Color.WHITE
# Determine color based on item type/rarity
if chest_item.item_type == Item.ItemType.Restoration: # Determine color based on item type/rarity
item_color = Color.GREEN if chest_item.item_type == Item.ItemType.Restoration:
elif chest_item.item_type == Item.ItemType.Equippable: item_color = Color.GREEN
item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) elif chest_item.item_type == Item.ItemType.Equippable:
else: item_color = Color.CYAN # Cyan for equipment (matches loot pickup color)
item_color = Color.WHITE else:
item_color = Color.WHITE
# Show notification with item sprite (pass chest_item for ItemSprite colorization)
if items_texture: # Show notification with item sprite (pass chest_item for ItemSprite colorization)
_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) if items_texture:
else: _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, null, 0, 0, 0, chest_item) else:
_show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item)
# Play chest open sound # Play get-item sound when receiving inventory item from chest
if has_node("SfxChestOpen"): if has_node("SfxGetItemFromChest"):
$SfxChestOpen.play() $SfxGetItemFromChest.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)
if multiplayer.has_multiplayer_peer():
# Sync chest opening visual to all clients (item already given on server) var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
if multiplayer.has_multiplayer_peer(): if game_world and game_world.has_method("_rpc_to_ready_peers"):
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 var chest_name = name
# Reuse game_world from earlier in the function if has_meta("object_index"):
if game_world and game_world.has_method("_rpc_to_ready_peers"): chest_name = "InteractableObject_%d" % get_meta("object_index")
var chest_name = name var item_data = chest_item.save() if chest_item else {}
if has_meta("object_index"): game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data])
chest_name = "InteractableObject_%d" % get_meta("object_index") if multiplayer.is_server():
# Sync chest open visual with item_data so clients can show the floating text var owner_id = by_player.get_multiplayer_authority()
var item_data = chest_item.save() if chest_item else {} if owner_id != 1 and owner_id != multiplayer.get_unique_id():
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data]) var inv_data: Array = []
# Sync inventory+equipment to joiner (server added item; joiner's client must apply) for inv_item in by_player.character_stats.inventory:
if multiplayer.is_server(): inv_data.append(inv_item.save() if inv_item else null)
var owner_id = by_player.get_multiplayer_authority() var equip_data: Dictionary = {}
if owner_id != 1 and owner_id != multiplayer.get_unique_id(): for slot_name in by_player.character_stats.equipment.keys():
var inv_data: Array = [] var eq = by_player.character_stats.equipment[slot_name]
for inv_item in by_player.character_stats.inventory: equip_data[slot_name] = eq.save() if eq else null
inv_data.append(inv_item.save() if inv_item else null) if by_player.has_method("_apply_inventory_and_equipment_from_server"):
var equip_data: Dictionary = {} by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
for slot_name in by_player.character_stats.equipment.keys(): else:
var eq = by_player.character_stats.equipment[slot_name] # Empty chest: show "EMPTY CHEST" floating text
equip_data[slot_name] = eq.save() if eq else null _show_item_pickup_notification(by_player, "EMPTY CHEST", Color(0.5, 0.5, 0.5))
if by_player.has_method("_apply_inventory_and_equipment_from_server"): print(name, " opened by ", by_player.name, " - empty chest")
by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) 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

@@ -40,8 +40,8 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main
# Bar layout constants (align X/Y + bar across rows) # Bar layout constants (align X/Y + bar across rows)
const _BAR_WIDTH: int = 100 const _BAR_WIDTH: int = 100
const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999" const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999"
const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:" const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:"
# Weight UI elements (created programmatically) # Weight UI elements (created programmatically)
var weight_container: HBoxContainer = null var weight_container: HBoxContainer = null
@@ -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
@@ -1042,7 +1043,7 @@ func _update_selection_from_navigation():
var row = inventory_rows_list[inventory_selection_row] var row = inventory_rows_list[inventory_selection_row]
print("InventoryUI: Checking item selection - row: ", inventory_selection_row, " col: ", inventory_selection_col, " row exists: ", row != null, " row child count: ", row.get_child_count() if row else 0) print("InventoryUI: Checking item selection - row: ", inventory_selection_row, " col: ", inventory_selection_col, " row exists: ", row != null, " row child count: ", row.get_child_count() if row else 0)
if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count(): if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count():
var items_per_row = 8 # Must match the items_per_row used when building rows var items_per_row = 8 # Must match the items_per_row used when building rows
var item_index = inventory_selection_row * items_per_row + inventory_selection_col var item_index = inventory_selection_row * items_per_row + inventory_selection_col
print("InventoryUI: Calculated item_index: ", item_index, " inventory_items_list.size(): ", inventory_items_list.size()) print("InventoryUI: Calculated item_index: ", item_index, " inventory_items_list.size(): ", inventory_items_list.size())
if item_index >= 0 and item_index < inventory_items_list.size(): if item_index >= 0 and item_index < inventory_items_list.size():
@@ -1368,7 +1369,7 @@ func _on_inventory_item_pressed(item: Item):
_update_selection_highlight() _update_selection_highlight()
_update_selection_rectangle() _update_selection_rectangle()
_update_info_panel() # Show item description on single-click _update_info_panel() # Show item description on single-click
func _on_inventory_item_gui_input(event: InputEvent, item: Item): func _on_inventory_item_gui_input(event: InputEvent, item: Item):
# Handle double-click to equip/consume and right-click to drop # Handle double-click to equip/consume and right-click to drop
@@ -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,13 +246,26 @@ 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:
# Sync removal to clients so joiner sees loot disappear (same as pickup) # Keys respawn on a safe tile (like player); other loot is removed
if multiplayer.has_multiplayer_peer() and is_inside_tree(): if loot_type == LootType.KEY:
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_remove"): if game_world and game_world.has_method("_get_nearest_safe_tile_center"):
game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) var safe_pos = game_world._get_nearest_safe_tile_center(global_position)
queue_free() _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)
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_loot_remove"):
game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position])
queue_free()
return return
scale = Vector2.ONE * max(0.0, fallout_sink_progress) scale = Vector2.ONE * max(0.0, fallout_sink_progress)
move_and_slide() move_and_slide()
@@ -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):
_set_animation("IDLE_PUSH") if is_pushing:
# Keep locked direction when pushing _set_animation("IDLE_PUSH")
# 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,7 +6591,8 @@ 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
_die() if is_instance_valid(self) and is_dead:
_die()
func _die(): func _die():
# Already processing death - prevent multiple concurrent death sequences # Already processing death - prevent multiple concurrent death sequences
@@ -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()
@@ -51,6 +55,13 @@ func _physics_process(delta):
# Decelerate quickly # Decelerate quickly
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
@@ -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

@@ -7,11 +7,11 @@ extends Node2D
@onready var detection_area = $DetectionArea @onready var detection_area = $DetectionArea
# Trap state # Trap state
var is_detected: bool = false # Becomes true when any player detects it var is_detected: bool = false # Becomes true when any player detects it
var is_disarmed: bool = false # True if trap has been disarmed var is_disarmed: bool = false # True if trap has been disarmed
var is_active: bool = false # True when trap is currently triggering var is_active: bool = false # True when trap is currently triggering
var has_cooldown: bool = false # Some traps can reset var has_cooldown: bool = false # Some traps can reset
var cooldown_time: float = 5.0 # Time until trap can re-activate var cooldown_time: float = 5.0 # Time until trap can re-activate
var cooldown_timer: float = 0.0 var cooldown_timer: float = 0.0
# Trap properties # Trap properties
@@ -44,7 +44,7 @@ func _ready() -> void:
if index == 0: if index == 0:
sprite.texture = load("res://assets/gfx/traps/Floor_Lance.png") sprite.texture = load("res://assets/gfx/traps/Floor_Lance.png")
trap_type = "Floor_Lance" trap_type = "Floor_Lance"
has_cooldown = true # Lance traps can reset has_cooldown = true # Lance traps can reset
# Start hidden (invisible until detected) # Start hidden (invisible until detected)
sprite.modulate.a = 0.0 sprite.modulate.a = 0.0
@@ -91,14 +91,14 @@ func _on_detection_area_body_entered(body: Node) -> void:
return return
if is_detected or is_disarmed: if is_detected or is_disarmed:
return # Already detected or disarmed return # Already detected or disarmed
# Get player peer ID # Get player peer ID
var peer_id = body.get_multiplayer_authority() var peer_id = body.get_multiplayer_authority()
# Check if this player has already attempted detection # Check if this player has already attempted detection
if player_detection_attempts.has(peer_id): if player_detection_attempts.has(peer_id):
return # Already tried once this game return # Already tried once this game
# Mark that this player has attempted detection # Mark that this player has attempted detection
player_detection_attempts[peer_id] = true player_detection_attempts[peer_id] = true
@@ -108,7 +108,7 @@ func _on_detection_area_body_entered(body: Node) -> void:
_roll_perception_check(body) _roll_perception_check(body)
func _on_detection_area_body_exited(_body: Node) -> void: func _on_detection_area_body_exited(_body: Node) -> void:
pass # Detection is permanent once attempted pass # Detection is permanent once attempted
func _roll_perception_check(player: Node) -> void: func _roll_perception_check(player: Node) -> void:
# Roll perception check for player # Roll perception check for player
@@ -119,8 +119,8 @@ func _roll_perception_check(player: Node) -> void:
# Perception roll: d20 + PER modifier # Perception roll: d20 + PER modifier
# Target DC: 15 (medium difficulty) # Target DC: 15 (medium difficulty)
var roll = randi() % 20 + 1 # 1d20 var roll = randi() % 20 + 1 # 1d20
var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2 var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2
var dc = 15 var dc = 15
print(player.name, " rolls Perception: ", roll, " + ", int(per_stat / 2) - 5, " = ", total, " vs DC ", dc) print(player.name, " rolls Perception: ", roll, " + ", int(per_stat / 2) - 5, " = ", total, " vs DC ", dc)
@@ -143,15 +143,20 @@ 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
print(detecting_player.name, " detected trap at ", global_position) print(detecting_player.name, " detected trap at ", global_position)
@@ -200,7 +205,7 @@ func _on_disarm_area_body_exited(body: Node) -> void:
func _show_disarm_text(_player: Node) -> void: func _show_disarm_text(_player: Node) -> void:
# Create "DISARM" label above trap # Create "DISARM" label above trap
if disarm_label: if disarm_label:
return # Already showing return # Already showing
disarm_label = Label.new() disarm_label = Label.new()
disarm_label.text = "DISARM" disarm_label.text = "DISARM"
@@ -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)
@@ -267,7 +285,7 @@ func _complete_disarm() -> void:
# Grant EXP to all players for disarming trap (only on server) # Grant EXP to all players for disarming trap (only on server)
# CRITICAL: Only server should grant EXP to avoid duplicates # CRITICAL: Only server should grant EXP to avoid duplicates
if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var trap_exp_reward = 8.0 # EXP reward for disarming a trap var trap_exp_reward = 8.0 # EXP reward for disarming a trap
var all_players = get_tree().get_nodes_in_group("player") var all_players = get_tree().get_nodes_in_group("player")
var valid_players = [] var valid_players = []
for player in all_players: for player in all_players:
@@ -303,7 +321,7 @@ func _complete_disarm() -> void:
if multiplayer.is_server(): if multiplayer.is_server():
# Host disarmed: broadcast to clients # Host disarmed: broadcast to clients
if game_world.has_method("_sync_trap_state_by_name"): if game_world.has_method("_sync_trap_state_by_name"):
game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true
else: else:
# Joiner disarmed: request host to apply locally and broadcast to all # Joiner disarmed: request host to apply locally and broadcast to all
if game_world.has_method("_request_trap_disarm"): if game_world.has_method("_request_trap_disarm"):
@@ -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:
@@ -335,7 +354,7 @@ func _show_exp_number(amount: float, exp_pos: Vector2):
# Set text and color for EXP (green) # Set text and color for EXP (green)
exp_label.label = "+" + str(int(amount)) + " EXP" exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5 exp_label.z_index = 5
# Direction is straight up # Direction is straight up
@@ -347,7 +366,7 @@ func _show_exp_number(amount: float, exp_pos: Vector2):
var entities_node = game_world.get_node_or_null("Entities") var entities_node = game_world.get_node_or_null("Entities")
if entities_node: if entities_node:
entities_node.add_child(exp_label) entities_node.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
else: else:
get_tree().current_scene.add_child(exp_label) get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) exp_label.global_position = exp_pos + Vector2(0, -20)
@@ -371,10 +390,10 @@ func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_
return return
if is_disarmed or is_active: if is_disarmed or is_active:
return # Can't trigger if disarmed or already active return # Can't trigger if disarmed or already active
if has_cooldown and cooldown_timer > 0: if has_cooldown and cooldown_timer > 0:
return # Still on cooldown return # Still on cooldown
# Trigger trap # Trigger trap
is_active = true is_active = true
@@ -392,7 +411,7 @@ func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_
# 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") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_trap_state_by_name"): 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
# Deal damage to player (with luck-based avoidance) # Deal damage to player (with luck-based avoidance)
_deal_trap_damage(body) _deal_trap_damage(body)
@@ -414,7 +433,7 @@ func _deal_trap_damage(player: Node) -> void:
# Luck-based avoidance check # Luck-based avoidance check
var luck_stat = player.character_stats.baseStats.lck + player.character_stats.get_pass("lck") var luck_stat = player.character_stats.baseStats.lck + player.character_stats.get_pass("lck")
var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid) var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid)
var avoid_roll = randf() var avoid_roll = randf()
if avoid_roll < avoid_chance: if avoid_roll < avoid_chance:
@@ -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"):
player.rpc_take_damage(trap_damage, global_position) 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)
print(player.name, " took ", final_damage, " trap damage") print(player.name, " took ", final_damage, " trap damage")