fix spider bat boss alittle
@@ -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
|
||||||
BIN
src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/Dungeon Teleporting.wav
Normal file
24
src/assets/audio/sfx/z3/Dungeon Teleporting.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/Hidden Treasure.wav
Normal file
24
src/assets/audio/sfx/z3/Hidden Treasure.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Enemy_Fall.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Enemy_Fall.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Enemy_Jump.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Enemy_Jump.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Get_Item2.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Get_Item2.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Ground_Crumble.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Ground_Crumble.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Link_Fall.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Link_Fall.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav
Normal file
24
src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Charge.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Charge.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Slash1.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Slash1.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Slash2.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Slash2.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Slash3.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Slash3.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Slash4.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Slash4.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Spin.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Spin.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Tap.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Tap.wav.import
Normal 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
|
||||||
BIN
src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav
Normal file
24
src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 7.5 KiB |
27
src/scenes/attack_web_shot.tscn
Normal 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"
|
||||||
@@ -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")
|
|
||||||
|
|||||||
442
src/scenes/boss_spider_bat.tscn
Normal 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"]
|
||||||
31
src/scenes/detected_effect.tscn
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
29
src/scenes/enemy_spider.tscn
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var punch_direction: Vector2 = Vector2.RIGHT
|
|||||||
var player_owner: Node = null
|
var player_owner: Node = null
|
||||||
var hit_targets: Dictionary = {}
|
var hit_targets: Dictionary = {}
|
||||||
var elapsed: float = 0.0
|
var elapsed: float = 0.0
|
||||||
|
var _cut_web_rpc_sent: bool = false
|
||||||
|
|
||||||
var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn")
|
var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect_punch.tscn")
|
||||||
|
|
||||||
@@ -23,8 +24,12 @@ var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if has_node("SfxSwosh"):
|
if has_node("SfxSwosh"):
|
||||||
$SfxSwosh.play()
|
$SfxSwosh.play()
|
||||||
if hit_area and not hit_area.body_entered.is_connected(_on_body_entered):
|
if hit_area:
|
||||||
|
if not hit_area.body_entered.is_connected(_on_body_entered):
|
||||||
hit_area.body_entered.connect(_on_body_entered)
|
hit_area.body_entered.connect(_on_body_entered)
|
||||||
|
if not hit_area.area_entered.is_connected(_on_area_entered):
|
||||||
|
hit_area.area_entered.connect(_on_area_entered)
|
||||||
|
hit_area.collision_mask = hit_area.collision_mask | 8
|
||||||
if sprite and PUNCH_FRAMES.size() > 0:
|
if sprite and PUNCH_FRAMES.size() > 0:
|
||||||
sprite.frame = PUNCH_FRAMES[0]
|
sprite.frame = PUNCH_FRAMES[0]
|
||||||
|
|
||||||
@@ -46,10 +51,21 @@ func _process(delta: float) -> void:
|
|||||||
if sprite and PUNCH_FRAMES.size() > 0:
|
if sprite and PUNCH_FRAMES.size() > 0:
|
||||||
var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1)
|
var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1)
|
||||||
sprite.frame = PUNCH_FRAMES[frame_idx]
|
sprite.frame = PUNCH_FRAMES[frame_idx]
|
||||||
|
# So other player can free netted teammate: notify server to cut web (web may only exist on server)
|
||||||
|
if not _cut_web_rpc_sent and multiplayer.has_multiplayer_peer() and not multiplayer.is_server() and player_owner and is_instance_valid(player_owner) and elapsed >= 0.03:
|
||||||
|
_cut_web_rpc_sent = true
|
||||||
|
var gw = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if gw and gw.has_method("request_cut_web"):
|
||||||
|
gw.request_cut_web.rpc_id(1, global_position.x, global_position.y, 28.0, player_owner.get_multiplayer_authority())
|
||||||
if elapsed >= lifetime:
|
if elapsed >= lifetime:
|
||||||
hit_area.set_deferred("monitoring", false)
|
hit_area.set_deferred("monitoring", false)
|
||||||
queue_free()
|
queue_free()
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if area.has_method("cut_by_attack") and area.get("state") == "hit_player":
|
||||||
|
if player_owner and is_instance_valid(player_owner) and player_owner != area.get("hit_player"):
|
||||||
|
area.cut_by_attack(player_owner)
|
||||||
|
|
||||||
func _on_body_entered(body: Node2D) -> void:
|
func _on_body_entered(body: Node2D) -> void:
|
||||||
if body == player_owner:
|
if body == player_owner:
|
||||||
return
|
return
|
||||||
@@ -77,6 +93,9 @@ func _on_body_entered(body: Node2D) -> void:
|
|||||||
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
|
game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, false)
|
||||||
else:
|
else:
|
||||||
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
|
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, false)
|
||||||
|
# Client: show damage number locally (boss/enemy won't show it on our view otherwise)
|
||||||
|
if game_world and game_world.has_method("show_damage_number_at_position") and not multiplayer.is_server():
|
||||||
|
game_world.show_damage_number_at_position(body.global_position, damage, false)
|
||||||
else:
|
else:
|
||||||
var enemy_peer_id = body.get_multiplayer_authority()
|
var enemy_peer_id = body.get_multiplayer_authority()
|
||||||
if enemy_peer_id != 0:
|
if enemy_peer_id != 0:
|
||||||
@@ -92,7 +111,10 @@ func _on_body_entered(body: Node2D) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Interactables with health (boxes, etc.) - small damage
|
# Interactables with health (boxes, etc.) - small damage
|
||||||
|
# Hidden chests must not be hit until detected (no damage, no effect)
|
||||||
if "health" in body:
|
if "health" in body:
|
||||||
|
if body.get("object_type") == "Chest" and body.get("is_hidden") and not body.get("is_detected"):
|
||||||
|
return
|
||||||
if has_node("SfxImpact"):
|
if has_node("SfxImpact"):
|
||||||
$SfxImpact.play()
|
$SfxImpact.play()
|
||||||
body.health -= damage
|
body.health -= damage
|
||||||
|
|||||||
172
src/scripts/attack_web_shot.gd
Normal 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()
|
||||||
1
src/scripts/attack_web_shot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bibyqdhticm5i
|
||||||
417
src/scripts/boss_spider_bat.gd
Normal 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()
|
||||||
1
src/scripts/boss_spider_bat.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bgdwn43m2yrtl
|
||||||
@@ -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
|
||||||
|
|||||||
68
src/scripts/detected_effect.gd
Normal 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
|
||||||
1
src/scripts/detected_effect.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b45h84vbq3jw
|
||||||
@@ -804,7 +804,35 @@ func _check_puzzle_state():
|
|||||||
switches_activated = false
|
switches_activated = false
|
||||||
puzzle_solved = false
|
puzzle_solved = false
|
||||||
|
|
||||||
|
func _are_all_enemies_defeated_boss_room() -> bool:
|
||||||
|
# Boss room: check ALL enemies in blocking_room by position (pre-placed boss counts)
|
||||||
|
var target_room = blocking_room if blocking_room and not blocking_room.is_empty() else {}
|
||||||
|
if target_room.is_empty():
|
||||||
|
return false
|
||||||
|
var tile_size = 16
|
||||||
|
var room_min_x = (target_room.x + 2) * tile_size
|
||||||
|
var room_max_x = (target_room.x + target_room.w - 2) * tile_size
|
||||||
|
var room_min_y = (target_room.y + 2) * tile_size
|
||||||
|
var room_max_y = (target_room.y + target_room.h - 2) * tile_size
|
||||||
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
|
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
|
||||||
|
if not entities_node:
|
||||||
|
return false
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if not child.is_in_group("enemy"):
|
||||||
|
continue
|
||||||
|
var pos = child.global_position
|
||||||
|
if pos.x < room_min_x or pos.x >= room_max_x or pos.y < room_min_y or pos.y >= room_max_y:
|
||||||
|
continue
|
||||||
|
var is_dead = child.is_dead if "is_dead" in child else (child.is_queued_for_deletion() or not child.is_inside_tree())
|
||||||
|
if not is_dead:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
func _are_all_enemies_defeated() -> bool:
|
func _are_all_enemies_defeated() -> bool:
|
||||||
|
# Boss room door: check ALL enemies in room (pre-placed boss), not just spawner-spawned
|
||||||
|
if has_meta("boss_room_door") and get_meta("boss_room_door"):
|
||||||
|
return _are_all_enemies_defeated_boss_room()
|
||||||
# Check if all enemies spawned from spawners in the puzzle room are defeated
|
# Check if all enemies spawned from spawners in the puzzle room are defeated
|
||||||
# CRITICAL: Only check enemies that were SPAWNED from spawners (not pre-spawned enemies)
|
# CRITICAL: Only check enemies that were SPAWNED from spawners (not pre-spawned enemies)
|
||||||
# Use room1 (the room this door is IN) or blocking_room for checking enemies
|
# Use room1 (the room this door is IN) or blocking_room for checking enemies
|
||||||
@@ -932,7 +960,6 @@ func _are_all_enemies_defeated() -> bool:
|
|||||||
# No spawned enemies found - check if spawners have actually spawned enemies before
|
# No spawned enemies found - check if spawners have actually spawned enemies before
|
||||||
# CRITICAL: Only consider puzzle solved if spawners have spawned enemies and they're all dead
|
# CRITICAL: Only consider puzzle solved if spawners have spawned enemies and they're all dead
|
||||||
# Don't solve if spawners haven't spawned yet (e.g., spawn_on_ready=false and player hasn't entered room)
|
# Don't solve if spawners haven't spawned yet (e.g., spawn_on_ready=false and player hasn't entered room)
|
||||||
|
|
||||||
var spawners_in_room = []
|
var spawners_in_room = []
|
||||||
var spawners_that_have_spawned = []
|
var spawners_that_have_spawned = []
|
||||||
|
|
||||||
@@ -1284,6 +1311,8 @@ func _sync_door_open():
|
|||||||
var is_actually_open = distance_to_closed > 5.0
|
var is_actually_open = distance_to_closed > 5.0
|
||||||
|
|
||||||
if not is_actually_open and not is_opening:
|
if not is_actually_open and not is_opening:
|
||||||
|
# Disable teleporter (stops particle emitter) when door opens
|
||||||
|
$TeleporterIntoClosedRoom.is_enabled = false
|
||||||
# Door is closed - open it
|
# Door is closed - open it
|
||||||
if closed_position != Vector2.ZERO:
|
if closed_position != Vector2.ZERO:
|
||||||
position = closed_position
|
position = closed_position
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ const FALLOUT_CORNER_INNER_DOWN_RIGHT = Vector2i(16, 13) # 302
|
|||||||
# But we want at least 3x3 floor, so room size should be at least 7x7 total
|
# But we want at least 3x3 floor, so room size should be at least 7x7 total
|
||||||
const MIN_ROOM_SIZE = 7 # Minimum room size in tiles (includes walls, so 3x3 floor minimum)
|
const MIN_ROOM_SIZE = 7 # Minimum room size in tiles (includes walls, so 3x3 floor minimum)
|
||||||
const MAX_ROOM_SIZE = 12 # Maximum room size in tiles
|
const MAX_ROOM_SIZE = 12 # Maximum room size in tiles
|
||||||
const MIN_HOLE_SIZE = 9 # Minimum hole size in rooms (9x9 tiles)
|
const LEVEL4_BOSS_ROOM_MIN_SIZE = 24 # Level 4: boss arena minimum (spider bat needs space)
|
||||||
|
const LEVEL4_BOSS_ROOM_MAX_SIZE = 26 # Level 4: much wider boss room (e.g. 26 tiles)
|
||||||
|
const LEVEL4_GEN_MAP_SIZE = 256 # Level 4: smaller canvas for faster gen; still enough for boss+exit+rooms; crop to content after
|
||||||
|
const LEVEL4_CROP_MARGIN = 4 # Level 4: margin when cropping dungeon to content
|
||||||
const DOOR_MIN_WIDTH = 3 # Minimum width for door frames
|
const DOOR_MIN_WIDTH = 3 # Minimum width for door frames
|
||||||
const DOOR_MAX_WIDTH = 5 # Maximum width for door frames
|
const DOOR_MAX_WIDTH = 5 # Maximum width for door frames
|
||||||
const CORRIDOR_WIDTH = 1 # Corridor width in tiles (1 tile)
|
const CORRIDOR_WIDTH = 1 # Corridor width in tiles (1 tile)
|
||||||
@@ -114,6 +117,11 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
else:
|
else:
|
||||||
rng.randomize()
|
rng.randomize()
|
||||||
|
|
||||||
|
# Level 4 (boss level): generate on a LARGE canvas; crop to content at the end
|
||||||
|
if level == 4:
|
||||||
|
map_size = Vector2i(LEVEL4_GEN_MAP_SIZE, LEVEL4_GEN_MAP_SIZE)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Using large canvas " + str(map_size.x) + "x" + str(map_size.y) + "; will crop to content after", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# Calculate target room count based on level
|
# Calculate target room count based on level
|
||||||
# Level 1: 7-8 rooms, then increase by 2-3 rooms per level
|
# Level 1: 7-8 rooms, then increase by 2-3 rooms per level
|
||||||
var target_room_count = 7 + (level - 1) * 2 + rng.randi_range(0, 1) # Level 1: 7-8, Level 2: 9-10, etc.
|
var target_room_count = 7 + (level - 1) * 2 + rng.randi_range(0, 1) # Level 1: 7-8, Level 2: 9-10, etc.
|
||||||
@@ -137,10 +145,62 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
|
|
||||||
var all_rooms = []
|
var all_rooms = []
|
||||||
var all_doors = []
|
var all_doors = []
|
||||||
|
var max_room_size = MAX_ROOM_SIZE
|
||||||
|
var level4_boss_room_size = LEVEL4_BOSS_ROOM_MAX_SIZE
|
||||||
|
|
||||||
# 1. Create first room at a random position
|
if level == 4:
|
||||||
var first_w = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE)
|
# --- LEVEL 4: BOSS first, then EXIT (only 1 door: boss<->exit), then MANY other rooms (only connected to boss). SPAWN = one of those other rooms. ---
|
||||||
var first_h = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE)
|
# 1. Create BOSS room first at center of the large canvas
|
||||||
|
var boss_w = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
|
||||||
|
var boss_h = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
|
||||||
|
var boss_room = {
|
||||||
|
"x": int((map_size.x - boss_w) / 2),
|
||||||
|
"y": int((map_size.y - boss_h) / 2),
|
||||||
|
"w": boss_w,
|
||||||
|
"h": boss_h,
|
||||||
|
"modifiers": []
|
||||||
|
}
|
||||||
|
_set_floor(boss_room, grid, tile_grid, map_size, rng)
|
||||||
|
all_rooms.append(boss_room)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Created BOSS room first at center (" + str(boss_room.x) + "," + str(boss_room.y) + ") size " + str(boss_room.w) + "x" + str(boss_room.h), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
|
# 2. Create EXIT room adjacent to BOSS. ONLY this room connects to exit. ONE door: boss <-> exit.
|
||||||
|
var exit_room = _try_place_room_near(boss_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
|
||||||
|
var exit_attempts = 50
|
||||||
|
while exit_room.w == 0 and exit_attempts > 0:
|
||||||
|
exit_room = _try_place_room_near(boss_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
|
||||||
|
exit_attempts -= 1
|
||||||
|
if exit_room.w > 0:
|
||||||
|
_set_floor(exit_room, grid, tile_grid, map_size, rng)
|
||||||
|
all_rooms.append(exit_room)
|
||||||
|
var boss_exit_door = _create_corridor_between_rooms(boss_room, exit_room, grid, tile_grid, map_size, all_rooms, rng)
|
||||||
|
if boss_exit_door.size() > 0:
|
||||||
|
all_doors.append(boss_exit_door)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - EXIT room attached to BOSS (only door to exit)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
else:
|
||||||
|
LogManager.log_error("DungeonGenerator: Level 4 - Failed to create boss<->exit corridor", LogManager.CATEGORY_DUNGEON)
|
||||||
|
else:
|
||||||
|
LogManager.log_error("DungeonGenerator: Level 4 - Could not place exit room adjacent to boss", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
|
# 3. Place MANY other rooms near the BOSS or near any existing "other" room (never near exit). So only the boss connects to exit; others attach to boss or to each other for more rooms.
|
||||||
|
var min_other_rooms = 10 # At least 10 other rooms for spawn candidates and path to boss
|
||||||
|
var target_other = maxi(min_other_rooms, target_room_count - 2) # boss + exit already; rest are "other"
|
||||||
|
var attempts = 6000
|
||||||
|
while attempts > 0 and (all_rooms.size() < 2 + target_other):
|
||||||
|
# Pick source: boss (index 0) or any existing "other" room (index 2+) so we build a cluster and get more rooms
|
||||||
|
var source_room = boss_room
|
||||||
|
if all_rooms.size() > 2 and rng.randf() < 0.6:
|
||||||
|
source_room = all_rooms[rng.randi_range(2, all_rooms.size() - 1)]
|
||||||
|
var new_room = _try_place_room_near(source_room, grid, map_size, rng, MAX_ROOM_SIZE, 4)
|
||||||
|
if new_room.w > 0:
|
||||||
|
_set_floor(new_room, grid, tile_grid, map_size, rng)
|
||||||
|
all_rooms.append(new_room)
|
||||||
|
attempts -= 1
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Generated " + str(all_rooms.size()) + " rooms (1 boss + 1 exit + " + str(all_rooms.size() - 2) + " others for spawn/path)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
else:
|
||||||
|
# --- Non-boss levels: first room random, then place others near any room ---
|
||||||
|
var first_w = rng.randi_range(MIN_ROOM_SIZE, max_room_size)
|
||||||
|
var first_h = rng.randi_range(MIN_ROOM_SIZE, max_room_size)
|
||||||
var first_room = {
|
var first_room = {
|
||||||
"x": rng.randi_range(4, map_size.x - first_w - 4),
|
"x": rng.randi_range(4, map_size.x - first_w - 4),
|
||||||
"y": rng.randi_range(4, map_size.y - first_h - 4),
|
"y": rng.randi_range(4, map_size.y - first_h - 4),
|
||||||
@@ -148,60 +208,58 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
"h": first_h,
|
"h": first_h,
|
||||||
"modifiers": []
|
"modifiers": []
|
||||||
}
|
}
|
||||||
|
|
||||||
_set_floor(first_room, grid, tile_grid, map_size, rng)
|
_set_floor(first_room, grid, tile_grid, map_size, rng)
|
||||||
all_rooms.append(first_room)
|
all_rooms.append(first_room)
|
||||||
|
var max_for_extra_rooms = max_room_size
|
||||||
# 2. Try to place rooms until we reach target count or can't fit any more
|
|
||||||
var attempts = 1000
|
var attempts = 1000
|
||||||
while attempts > 0 and all_rooms.size() < target_room_count and all_rooms.size() > 0:
|
while attempts > 0 and all_rooms.size() < target_room_count and all_rooms.size() > 0:
|
||||||
var source_room = all_rooms[rng.randi() % all_rooms.size()]
|
var source_room = all_rooms[rng.randi() % all_rooms.size()]
|
||||||
var new_room = _try_place_room_near(source_room, grid, map_size, rng)
|
var new_room = _try_place_room_near(source_room, grid, map_size, rng, max_for_extra_rooms, level)
|
||||||
|
|
||||||
if new_room.w > 0:
|
if new_room.w > 0:
|
||||||
_set_floor(new_room, grid, tile_grid, map_size, rng)
|
_set_floor(new_room, grid, tile_grid, map_size, rng)
|
||||||
all_rooms.append(new_room)
|
all_rooms.append(new_room)
|
||||||
|
|
||||||
attempts -= 1
|
attempts -= 1
|
||||||
|
|
||||||
LogManager.log("DungeonGenerator: Generated " + str(all_rooms.size()) + " rooms (target was " + str(target_room_count) + ")", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Generated " + str(all_rooms.size()) + " rooms (target was " + str(target_room_count) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# 3. Connect rooms with corridors/doors
|
# 3. Connect rooms with corridors/doors (level 4 already has boss<->exit; this connects the rest)
|
||||||
if all_rooms.size() > 1:
|
if all_rooms.size() > 1:
|
||||||
_connect_rooms(all_rooms, grid, tile_grid, map_size, all_doors, rng)
|
_connect_rooms(all_rooms, grid, tile_grid, map_size, all_doors, rng)
|
||||||
|
|
||||||
# 4. Add random holes in some rooms (minimum 9x9 tiles)
|
# 4. (No holes - we only use sprinkled single fallout tiles and cracked tiles)
|
||||||
for room in all_rooms:
|
|
||||||
if rng.randf() < 0.3: # 30% chance for a hole
|
|
||||||
_add_hole_to_room(room, grid, tile_grid, map_size, rng)
|
|
||||||
|
|
||||||
# 5. Mark start room (random room for variety)
|
# 5 & 6. Start/exit and reachability
|
||||||
var start_room_index = rng.randi() % all_rooms.size()
|
# Level 4: GUARANTEE boss room (index 0), spawn → boss → exit. We use the BOSS ROOM as the connectivity root:
|
||||||
|
# only keep rooms reachable FROM the boss, so the boss always has doors and the path is spawn → boss → exit.
|
||||||
|
# Other levels: pick random start, then keep rooms reachable from start.
|
||||||
|
var start_room_index: int
|
||||||
var exit_room_index = -1 # Declare exit_room_index early to avoid scope issues
|
var exit_room_index = -1 # Declare exit_room_index early to avoid scope issues
|
||||||
|
var reachable_rooms: Array
|
||||||
|
if level == 4:
|
||||||
|
# Level 4: reachability from BOSS room (index 0) so we never strip doors from the boss
|
||||||
|
reachable_rooms = _find_reachable_rooms(all_rooms[0], all_rooms, all_doors)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Found " + str(reachable_rooms.size()) + " rooms reachable from BOSS room (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
else:
|
||||||
|
start_room_index = rng.randi() % all_rooms.size()
|
||||||
all_rooms[start_room_index].modifiers.append({"type": "START"})
|
all_rooms[start_room_index].modifiers.append({"type": "START"})
|
||||||
|
reachable_rooms = _find_reachable_rooms(all_rooms[start_room_index], all_rooms, all_doors)
|
||||||
# 6. Mark exit room (farthest REACHABLE room from start)
|
|
||||||
# First find all reachable rooms from start
|
|
||||||
var reachable_rooms = _find_reachable_rooms(all_rooms[start_room_index], all_rooms, all_doors)
|
|
||||||
LogManager.log("DungeonGenerator: Found " + str(reachable_rooms.size()) + " reachable rooms from start (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Found " + str(reachable_rooms.size()) + " reachable rooms from start (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# CRITICAL: Remove inaccessible rooms (rooms not reachable from start)
|
# CRITICAL: Remove inaccessible rooms (rooms not in reachable_rooms)
|
||||||
# Store the start room before filtering (it should always be reachable)
|
var start_room_ref: Dictionary
|
||||||
var start_room_ref = all_rooms[start_room_index]
|
if level != 4:
|
||||||
|
start_room_ref = all_rooms[start_room_index]
|
||||||
var inaccessible_count = 0
|
var inaccessible_count = 0
|
||||||
|
|
||||||
# Create new array with only reachable rooms
|
# Create new array with only reachable rooms (value-based comparison)
|
||||||
# Use value-based comparison (x, y, w, h) to check if room is reachable
|
|
||||||
var filtered_rooms = []
|
var filtered_rooms = []
|
||||||
for room in all_rooms:
|
for idx in range(all_rooms.size()):
|
||||||
|
var room = all_rooms[idx]
|
||||||
var is_reachable = false
|
var is_reachable = false
|
||||||
# Check if this room is in the reachable_rooms list by comparing values
|
|
||||||
for reachable_room in reachable_rooms:
|
for reachable_room in reachable_rooms:
|
||||||
if reachable_room.x == room.x and reachable_room.y == room.y and \
|
if reachable_room.x == room.x and reachable_room.y == room.y and \
|
||||||
reachable_room.w == room.w and reachable_room.h == room.h:
|
reachable_room.w == room.w and reachable_room.h == room.h:
|
||||||
is_reachable = true
|
is_reachable = true
|
||||||
break
|
break
|
||||||
|
|
||||||
if is_reachable:
|
if is_reachable:
|
||||||
filtered_rooms.append(room)
|
filtered_rooms.append(room)
|
||||||
else:
|
else:
|
||||||
@@ -214,7 +272,9 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
if inaccessible_count > 0:
|
if inaccessible_count > 0:
|
||||||
LogManager.log("DungeonGenerator: Removed " + str(inaccessible_count) + " inaccessible room(s). Remaining rooms: " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Removed " + str(inaccessible_count) + " inaccessible room(s). Remaining rooms: " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# Update start_room_index after filtering (find start room in new array using value-based comparison)
|
# Level 4: boss room is always index 0 in filtered list (first room was the root). Assign start/exit in level-4 block below.
|
||||||
|
# Non-level-4: update start_room_index after filtering
|
||||||
|
if level != 4:
|
||||||
start_room_index = -1
|
start_room_index = -1
|
||||||
for i in range(all_rooms.size()):
|
for i in range(all_rooms.size()):
|
||||||
var room = all_rooms[i]
|
var room = all_rooms[i]
|
||||||
@@ -222,7 +282,6 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
room.w == start_room_ref.w and room.h == start_room_ref.h:
|
room.w == start_room_ref.w and room.h == start_room_ref.h:
|
||||||
start_room_index = i
|
start_room_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
if start_room_index == -1:
|
if start_room_index == -1:
|
||||||
LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON)
|
LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON)
|
||||||
start_room_index = 0 # Fallback
|
start_room_index = 0 # Fallback
|
||||||
@@ -255,6 +314,8 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
if door_room1_reachable and door_room2_reachable:
|
if door_room1_reachable and door_room2_reachable:
|
||||||
filtered_doors.append(door)
|
filtered_doors.append(door)
|
||||||
else:
|
else:
|
||||||
|
# Fill door and corridor with wall so we don't leave orphan corridors (corridor with no door)
|
||||||
|
_fill_door_tiles_as_wall(door, grid, tile_grid, map_size)
|
||||||
doors_removed += 1
|
doors_removed += 1
|
||||||
LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
@@ -262,24 +323,44 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
if doors_removed > 0:
|
if doors_removed > 0:
|
||||||
LogManager.log("DungeonGenerator: Removed " + str(doors_removed) + " door(s) connected to inaccessible rooms. Remaining doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Removed " + str(doors_removed) + " door(s) connected to inaccessible rooms. Remaining doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
|
# Boss room for level 4 (used to exclude from fallout); empty for other levels
|
||||||
|
var boss_room_for_fallout: Dictionary = {}
|
||||||
# Find the farthest reachable room (now all rooms are reachable, but find farthest)
|
# Find the farthest reachable room (now all rooms are reachable, but find farthest)
|
||||||
# Make sure we have at least 2 rooms (start and exit must be different)
|
|
||||||
# exit_room_index is already declared at function level
|
# exit_room_index is already declared at function level
|
||||||
if all_rooms.size() < 2:
|
if all_rooms.size() == 0:
|
||||||
LogManager.log_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
|
|
||||||
# Use start room as exit if only one room exists (shouldn't happen, but handle gracefully)
|
|
||||||
if all_rooms.size() == 1:
|
|
||||||
exit_room_index = 0
|
|
||||||
else:
|
|
||||||
# No rooms at all - this is a critical error
|
|
||||||
LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_DUNGEON)
|
LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_DUNGEON)
|
||||||
return {} # Return empty dungeon
|
return {} # Return empty dungeon
|
||||||
|
|
||||||
|
# Level 4: Boss room 0, exit room 1 (only 1 door: boss<->exit). SPAWN = one of the "other" rooms (index 2+), never boss or exit.
|
||||||
|
if level == 4:
|
||||||
|
var boss_room_index_l4 = 0
|
||||||
|
var exit_room_index_l4 = 1
|
||||||
|
# Start room MUST be one of the "other" rooms (index 2+), so player never spawns in boss or exit
|
||||||
|
var start_candidates = []
|
||||||
|
for i in range(2, all_rooms.size()):
|
||||||
|
start_candidates.append(i)
|
||||||
|
if start_candidates.is_empty():
|
||||||
|
# Fallback if we only have boss+exit (shouldn't happen - we place min 6 other rooms)
|
||||||
|
start_candidates.append(0)
|
||||||
|
start_room_index = start_candidates[rng.randi() % start_candidates.size()]
|
||||||
|
all_rooms[start_room_index].modifiers.append({"type": "START"})
|
||||||
|
exit_room_index = exit_room_index_l4
|
||||||
|
all_rooms[boss_room_index_l4].modifiers.append({"type": "BOSS"})
|
||||||
|
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
|
||||||
|
boss_room_for_fallout = all_rooms[boss_room_index_l4]
|
||||||
|
# CRITICAL: Exit room must have ONLY ONE door (to the boss). Remove any other doors that _connect_rooms may have added.
|
||||||
|
_remove_doors_from_exit_room_except_boss(all_rooms, all_doors, grid, tile_grid, map_size, exit_room_index, boss_room_index_l4)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Boss " + str(boss_room_index_l4) + ", exit " + str(exit_room_index) + " (1 door only), start " + str(start_room_index) + " (other room)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
elif all_rooms.size() < 2:
|
||||||
|
LogManager.log_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON)
|
||||||
|
exit_room_index = 0
|
||||||
else:
|
else:
|
||||||
exit_room_index = _find_farthest_room(all_rooms, start_room_index)
|
exit_room_index = _find_farthest_room(all_rooms, start_room_index)
|
||||||
|
|
||||||
# Make sure exit room is different from start room
|
# Make sure exit room is different from start room (non-level-4 path)
|
||||||
|
if level != 4:
|
||||||
|
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
|
||||||
if exit_room_index == start_room_index and all_rooms.size() > 1:
|
if exit_room_index == start_room_index and all_rooms.size() > 1:
|
||||||
# If exit is same as start, find second farthest
|
|
||||||
var max_distance = 0
|
var max_distance = 0
|
||||||
var second_farthest = -1
|
var second_farthest = -1
|
||||||
for i in range(all_rooms.size()):
|
for i in range(all_rooms.size()):
|
||||||
@@ -291,9 +372,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
second_farthest = i
|
second_farthest = i
|
||||||
if second_farthest != -1:
|
if second_farthest != -1:
|
||||||
exit_room_index = second_farthest
|
exit_room_index = second_farthest
|
||||||
|
LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index), LogManager.CATEGORY_DUNGEON)
|
||||||
all_rooms[exit_room_index].modifiers.append({"type": "EXIT"})
|
|
||||||
LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index) + " position: " + str(all_rooms[exit_room_index].x) + "," + str(all_rooms[exit_room_index].y), LogManager.CATEGORY_DUNGEON)
|
|
||||||
|
|
||||||
# 7. Render walls around rooms
|
# 7. Render walls around rooms
|
||||||
_render_room_walls(all_rooms, grid, tile_grid, map_size, rng)
|
_render_room_walls(all_rooms, grid, tile_grid, map_size, rng)
|
||||||
@@ -305,8 +384,10 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
_fill_decorated_tile_grid(grid, decorated_tile_grid, map_size, rng)
|
_fill_decorated_tile_grid(grid, decorated_tile_grid, map_size, rng)
|
||||||
# 7.44. Sprinkle cracked floor (15,16) on some floor; never in doors, corridors, in front of doors, or in start room
|
# 7.44. Sprinkle cracked floor (15,16) on some floor; never in doors, corridors, in front of doors, or in start room
|
||||||
_fill_cracked_tile_grid(grid, cracked_tile_grid, map_size, all_doors, all_rooms[start_room_index], rng)
|
_fill_cracked_tile_grid(grid, cracked_tile_grid, map_size, all_doors, all_rooms[start_room_index], rng)
|
||||||
# 7.45. Sprinkle fallout tiles on some floor (cracked/worn look); never in doors, corridors, in front of doors, or in start room
|
# 7.45. Sprinkle fallout tiles on some floor (cracked/worn look); never in doors, corridors, in front of doors, start room, or boss room (level 4). Level 4 uses lower chance.
|
||||||
_render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng)
|
_render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng, boss_room_for_fallout, level)
|
||||||
|
# 7.455. Cracked floor must never be on fallout tiles: clear cracked where fallout was placed
|
||||||
|
_clear_cracked_on_fallout(tile_grid, cracked_tile_grid, map_size)
|
||||||
# 7.46. Keep fallout tiles free from decorated layer (no decorated tiles on top of fallout)
|
# 7.46. Keep fallout tiles free from decorated layer (no decorated tiles on top of fallout)
|
||||||
_clear_decorated_on_fallout(tile_grid, decorated_tile_grid, map_size)
|
_clear_decorated_on_fallout(tile_grid, decorated_tile_grid, map_size)
|
||||||
|
|
||||||
@@ -340,7 +421,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
|
|
||||||
# 11. Place blocking doors on existing tile doors (after everything else is created)
|
# 11. Place blocking doors on existing tile doors (after everything else is created)
|
||||||
# IMPORTANT: This must happen BEFORE placing enemies, so we know which rooms have monster spawner puzzles
|
# IMPORTANT: This must happen BEFORE placing enemies, so we know which rooms have monster spawner puzzles
|
||||||
var blocking_doors_result = _place_blocking_doors(all_rooms, all_doors, grid, map_size, rng, start_room_index, exit_room_index)
|
var blocking_doors_result = _place_blocking_doors(all_rooms, all_doors, grid, map_size, rng, start_room_index, exit_room_index, level)
|
||||||
var blocking_doors = blocking_doors_result.doors if blocking_doors_result.has("doors") else blocking_doors_result
|
var blocking_doors = blocking_doors_result.doors if blocking_doors_result.has("doors") else blocking_doors_result
|
||||||
var room_puzzle_data = blocking_doors_result.puzzle_data if blocking_doors_result.has("puzzle_data") else {}
|
var room_puzzle_data = blocking_doors_result.puzzle_data if blocking_doors_result.has("puzzle_data") else {}
|
||||||
|
|
||||||
@@ -384,6 +465,15 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
var room = all_rooms[i]
|
var room = all_rooms[i]
|
||||||
# Skip start room and exit room
|
# Skip start room and exit room
|
||||||
if i != start_room_index and i != exit_room_index:
|
if i != start_room_index and i != exit_room_index:
|
||||||
|
# Level 4: skip BOSS room (boss is placed separately)
|
||||||
|
var is_boss_room = false
|
||||||
|
if room.get("modifiers") is Array:
|
||||||
|
for mod in room.modifiers:
|
||||||
|
if mod is Dictionary and mod.get("type") == "BOSS":
|
||||||
|
is_boss_room = true
|
||||||
|
break
|
||||||
|
if is_boss_room:
|
||||||
|
continue
|
||||||
# CRITICAL: Skip rooms that have monster spawner puzzles (these will spawn enemies when player enters)
|
# CRITICAL: Skip rooms that have monster spawner puzzles (these will spawn enemies when player enters)
|
||||||
var has_spawner_puzzle = false
|
var has_spawner_puzzle = false
|
||||||
for spawner_room in rooms_with_spawner_puzzles:
|
for spawner_room in rooms_with_spawner_puzzles:
|
||||||
@@ -397,17 +487,135 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
|||||||
var room_enemies = _place_enemies_in_room(room, grid, map_size, rng, level)
|
var room_enemies = _place_enemies_in_room(room, grid, map_size, rng, level)
|
||||||
all_enemies.append_array(room_enemies)
|
all_enemies.append_array(room_enemies)
|
||||||
|
|
||||||
|
# 9.5. Level 4: add boss spider bat in BOSS room center (exit room has stairs and is after boss room)
|
||||||
|
if level == 4:
|
||||||
|
var boss_room = null
|
||||||
|
for room in all_rooms:
|
||||||
|
if room.get("modifiers") is Array:
|
||||||
|
for mod in room.modifiers:
|
||||||
|
if mod is Dictionary and mod.get("type") == "BOSS":
|
||||||
|
boss_room = room
|
||||||
|
break
|
||||||
|
if boss_room != null:
|
||||||
|
break
|
||||||
|
if boss_room != null:
|
||||||
|
var tile_size = 16
|
||||||
|
var center_x = (boss_room.x + boss_room.w / 2.0) * tile_size
|
||||||
|
var center_y = (boss_room.y + boss_room.h / 2.0) * tile_size
|
||||||
|
var boss_data = {
|
||||||
|
"type": "res://scenes/boss_spider_bat.tscn",
|
||||||
|
"position": Vector2(center_x, center_y),
|
||||||
|
"room": boss_room,
|
||||||
|
"max_health": 1200.0,
|
||||||
|
"move_speed": 60.0,
|
||||||
|
"damage": 15.0,
|
||||||
|
"is_boss": true
|
||||||
|
}
|
||||||
|
all_enemies.append(boss_data)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Added boss_spider_bat in BOSS room at " + str(boss_data.position), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# 9.5. Place interactable objects in rooms (excluding start and exit rooms)
|
# 9.5. Place interactable objects in rooms (excluding start and exit rooms)
|
||||||
|
# For pillar-switch rooms, collect switch tile(s) so we never place the pillar on the switch (would pre-solve the puzzle)
|
||||||
var all_interactable_objects = []
|
var all_interactable_objects = []
|
||||||
for i in range(all_rooms.size()):
|
for i in range(all_rooms.size()):
|
||||||
var room = all_rooms[i]
|
var room = all_rooms[i]
|
||||||
# Skip start room and exit room
|
# Skip start room and exit room
|
||||||
if i != start_room_index and i != exit_room_index:
|
if i != start_room_index and i != exit_room_index:
|
||||||
var room_objects = _place_interactable_objects_in_room(room, grid, tile_grid, map_size, all_doors, all_enemies, rng, room_puzzle_data)
|
var exclude_switch_tiles: Array = []
|
||||||
|
for door_data in blocking_doors_array:
|
||||||
|
if door_data.get("puzzle_type") != "switch_pillar":
|
||||||
|
continue
|
||||||
|
var block_room = door_data.get("blocking_room") if door_data.get("blocking_room") is Dictionary else {}
|
||||||
|
if block_room.is_empty() or block_room.x != room.x or block_room.y != room.y or block_room.w != room.w or block_room.h != room.h:
|
||||||
|
continue
|
||||||
|
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
|
||||||
|
var st = Vector2i(int(door_data.switch_tile_x), int(door_data.switch_tile_y))
|
||||||
|
if not exclude_switch_tiles.has(st):
|
||||||
|
exclude_switch_tiles.append(st)
|
||||||
|
var room_objects = _place_interactable_objects_in_room(room, grid, tile_grid, map_size, all_doors, all_enemies, rng, room_puzzle_data, exclude_switch_tiles)
|
||||||
all_interactable_objects.append_array(room_objects)
|
all_interactable_objects.append_array(room_objects)
|
||||||
|
|
||||||
# 9.6. Place traps (1-2 per level, excluding start and exit rooms)
|
# 9.6. Place traps (1-2 per level, excluding start and exit rooms; never on fallout tiles)
|
||||||
var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, map_size, rng)
|
var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, tile_grid, map_size, rng)
|
||||||
|
|
||||||
|
# Level 4: crop dungeon to content (so final map is only as big as rooms + margin, not 512x512)
|
||||||
|
if level == 4:
|
||||||
|
var min_x = map_size.x
|
||||||
|
var min_y = map_size.y
|
||||||
|
var max_x = 0
|
||||||
|
var max_y = 0
|
||||||
|
for room in all_rooms:
|
||||||
|
min_x = mini(min_x, room.x)
|
||||||
|
min_y = mini(min_y, room.y)
|
||||||
|
max_x = maxi(max_x, room.x + room.w)
|
||||||
|
max_y = maxi(max_y, room.y + room.h)
|
||||||
|
for door in all_doors:
|
||||||
|
var dx = door.get("x", 0)
|
||||||
|
var dy = door.get("y", 0)
|
||||||
|
min_x = mini(min_x, dx)
|
||||||
|
min_y = mini(min_y, dy)
|
||||||
|
max_x = maxi(max_x, dx + door.get("w", 3))
|
||||||
|
max_y = maxi(max_y, dy + door.get("h", 2))
|
||||||
|
var margin = LEVEL4_CROP_MARGIN
|
||||||
|
var offset_x = maxi(0, min_x - margin)
|
||||||
|
var offset_y = maxi(0, min_y - margin)
|
||||||
|
max_x = mini(map_size.x, max_x + margin)
|
||||||
|
max_y = mini(map_size.y, max_y + margin)
|
||||||
|
var new_w = max_x - offset_x
|
||||||
|
var new_h = max_y - offset_y
|
||||||
|
# Build new grids
|
||||||
|
var new_grid = []
|
||||||
|
var new_tile_grid = []
|
||||||
|
var new_decorated_tile_grid = []
|
||||||
|
var new_cracked_tile_grid = []
|
||||||
|
for x in range(new_w):
|
||||||
|
new_grid.append([])
|
||||||
|
new_tile_grid.append([])
|
||||||
|
new_decorated_tile_grid.append([])
|
||||||
|
new_cracked_tile_grid.append([])
|
||||||
|
for y in range(new_h):
|
||||||
|
var ox = x + offset_x
|
||||||
|
var oy = y + offset_y
|
||||||
|
new_grid[x].append(grid[ox][oy] if ox < grid.size() and oy < grid[ox].size() else 0)
|
||||||
|
new_tile_grid[x].append(tile_grid[ox][oy] if ox < tile_grid.size() and oy < tile_grid[ox].size() else Vector2i(0, 0))
|
||||||
|
new_decorated_tile_grid[x].append(decorated_tile_grid[ox][oy] if ox < decorated_tile_grid.size() and oy < decorated_tile_grid[ox].size() else null)
|
||||||
|
new_cracked_tile_grid[x].append(cracked_tile_grid[ox][oy] if ox < cracked_tile_grid.size() and oy < cracked_tile_grid[ox].size() else false)
|
||||||
|
grid = new_grid
|
||||||
|
tile_grid = new_tile_grid
|
||||||
|
decorated_tile_grid = new_decorated_tile_grid
|
||||||
|
cracked_tile_grid = new_cracked_tile_grid
|
||||||
|
map_size = Vector2i(new_w, new_h)
|
||||||
|
var tile_size = 16
|
||||||
|
var world_offset = Vector2(offset_x * tile_size, offset_y * tile_size)
|
||||||
|
for room in all_rooms:
|
||||||
|
room.x -= offset_x
|
||||||
|
room.y -= offset_y
|
||||||
|
for door in all_doors:
|
||||||
|
door.x -= offset_x
|
||||||
|
door.y -= offset_y
|
||||||
|
if stairs_data.size() > 0:
|
||||||
|
stairs_data.x -= offset_x
|
||||||
|
stairs_data.y -= offset_y
|
||||||
|
if stairs_data.has("world_pos"):
|
||||||
|
stairs_data.world_pos -= world_offset
|
||||||
|
if entrance_data.size() > 0:
|
||||||
|
entrance_data.x -= offset_x
|
||||||
|
entrance_data.y -= offset_y
|
||||||
|
if entrance_data.has("world_pos"):
|
||||||
|
entrance_data.world_pos -= world_offset
|
||||||
|
var doors_to_shift = blocking_doors if blocking_doors is Array else blocking_doors.get("doors", [])
|
||||||
|
for door_data in doors_to_shift:
|
||||||
|
if door_data.has("switch_tile_x"): door_data.switch_tile_x -= offset_x
|
||||||
|
if door_data.has("switch_tile_y"): door_data.switch_tile_y -= offset_y
|
||||||
|
for e in all_enemies:
|
||||||
|
if e.has("position"): e.position -= world_offset
|
||||||
|
for t in all_traps:
|
||||||
|
if t.has("position"): t.position -= world_offset
|
||||||
|
for obj in all_interactable_objects:
|
||||||
|
if obj.has("position"): obj.position -= world_offset
|
||||||
|
for torch_data in all_torches:
|
||||||
|
if torch_data.has("position"): torch_data.position -= world_offset
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Cropped to " + str(new_w) + "x" + str(new_h) + " (offset " + str(offset_x) + "," + str(offset_y) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# NOTE: Stairs placement was moved earlier (step 7.5, before torches) to prevent torch overlap
|
# NOTE: Stairs placement was moved earlier (step 7.5, before torches) to prevent torch overlap
|
||||||
# NOTE: Blocking doors placement was moved earlier (step 11, before enemy placement) to identify spawner puzzle rooms
|
# NOTE: Blocking doors placement was moved earlier (step 11, before enemy placement) to identify spawner puzzle rooms
|
||||||
@@ -439,11 +647,12 @@ func _set_floor(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vecto
|
|||||||
grid[x][y] = 1 # Floor
|
grid[x][y] = 1 # Floor
|
||||||
tile_grid[x][y] = FLOOR_BASE
|
tile_grid[x][y] = FLOOR_BASE
|
||||||
|
|
||||||
func _try_place_room_near(source_room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Dictionary:
|
func _try_place_room_near(source_room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, max_room_size_param: int = MAX_ROOM_SIZE, _level: int = 1) -> Dictionary:
|
||||||
var attempts = 20
|
# Level 4: more attempts per call because the large boss room makes placement harder
|
||||||
|
var attempts = 50 if _level == 4 else 20
|
||||||
while attempts > 0:
|
while attempts > 0:
|
||||||
var w = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE)
|
var w = rng.randi_range(MIN_ROOM_SIZE, max_room_size_param)
|
||||||
var h = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE)
|
var h = rng.randi_range(MIN_ROOM_SIZE, max_room_size_param)
|
||||||
|
|
||||||
# Try all four sides of the source room
|
# Try all four sides of the source room
|
||||||
var sides = ["N", "S", "E", "W"]
|
var sides = ["N", "S", "E", "W"]
|
||||||
@@ -603,9 +812,19 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
|
|||||||
|
|
||||||
# Create corridor (1 tile wide) - base floor (0,15)
|
# Create corridor (1 tile wide) - base floor (0,15)
|
||||||
for x in range(wall_x + 1, wall_x + corridor_length + 1): # Corridor starts after the wall
|
for x in range(wall_x + 1, wall_x + corridor_length + 1): # Corridor starts after the wall
|
||||||
if x >= 0 and x < map_size.x and door_y + 1 >= 0 and door_y + 1 < map_size.y:
|
if x >= 0 and x < map_size.x and corridor_y >= 0 and corridor_y < map_size.y:
|
||||||
grid[x][door_y + 1] = 3 # Corridor (middle row of door)
|
grid[x][corridor_y] = 3 # Corridor (middle row of door)
|
||||||
tile_grid[x][door_y + 1] = FLOOR_BASE
|
tile_grid[x][corridor_y] = FLOOR_BASE
|
||||||
|
# Corridor walls: 2 tiles above = WALL_TOP_UPPER, WALL_TOP_LOWER; 2 tiles below = WALL_BOTTOM_UPPER, WALL_BOTTOM_LOWER (only where grid is wall)
|
||||||
|
if x >= 0 and x < map_size.x:
|
||||||
|
if corridor_y - 2 >= 0 and grid[x][corridor_y - 2] == 0:
|
||||||
|
tile_grid[x][corridor_y - 2] = WALL_TOP_UPPER
|
||||||
|
if corridor_y - 1 >= 0 and grid[x][corridor_y - 1] == 0:
|
||||||
|
tile_grid[x][corridor_y - 1] = WALL_TOP_LOWER
|
||||||
|
if corridor_y + 1 < map_size.y and grid[x][corridor_y + 1] == 0:
|
||||||
|
tile_grid[x][corridor_y + 1] = WALL_BOTTOM_UPPER
|
||||||
|
if corridor_y + 2 < map_size.y and grid[x][corridor_y + 2] == 0:
|
||||||
|
tile_grid[x][corridor_y + 2] = WALL_BOTTOM_LOWER
|
||||||
|
|
||||||
# Create door on RIGHT wall of left room (2x3 tiles - 2 wide, 3 tall)
|
# Create door on RIGHT wall of left room (2x3 tiles - 2 wide, 3 tall)
|
||||||
# Door is placed ON the wall, replacing the 2-tile wide wall
|
# Door is placed ON the wall, replacing the 2-tile wide wall
|
||||||
@@ -691,9 +910,19 @@ func _create_corridor_between_rooms(room1: Dictionary, room2: Dictionary, grid:
|
|||||||
|
|
||||||
# Create corridor (1 tile wide) - base floor (0,15)
|
# Create corridor (1 tile wide) - base floor (0,15)
|
||||||
for y in range(wall_y + 1, wall_y + corridor_length + 1): # Corridor starts after the wall
|
for y in range(wall_y + 1, wall_y + corridor_length + 1): # Corridor starts after the wall
|
||||||
if door_x + 1 >= 0 and door_x + 1 < map_size.x and y >= 0 and y < map_size.y:
|
if corridor_x >= 0 and corridor_x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
grid[door_x + 1][y] = 3 # Corridor (middle column of door)
|
grid[corridor_x][y] = 3 # Corridor (middle column of door)
|
||||||
tile_grid[door_x + 1][y] = FLOOR_BASE
|
tile_grid[corridor_x][y] = FLOOR_BASE
|
||||||
|
# Corridor walls: left 2 tiles = WALL_LEFT_LEFT, WALL_LEFT_RIGHT; right 2 tiles = WALL_RIGHT_LEFT, WALL_RIGHT_RIGHT (only where grid is wall)
|
||||||
|
if y >= 0 and y < map_size.y:
|
||||||
|
if corridor_x - 2 >= 0 and grid[corridor_x - 2][y] == 0:
|
||||||
|
tile_grid[corridor_x - 2][y] = WALL_LEFT_LEFT
|
||||||
|
if corridor_x - 1 >= 0 and grid[corridor_x - 1][y] == 0:
|
||||||
|
tile_grid[corridor_x - 1][y] = WALL_LEFT_RIGHT
|
||||||
|
if corridor_x + 1 < map_size.x and grid[corridor_x + 1][y] == 0:
|
||||||
|
tile_grid[corridor_x + 1][y] = WALL_RIGHT_LEFT
|
||||||
|
if corridor_x + 2 < map_size.x and grid[corridor_x + 2][y] == 0:
|
||||||
|
tile_grid[corridor_x + 2][y] = WALL_RIGHT_RIGHT
|
||||||
|
|
||||||
# Create door on BOTTOM wall of top room (3x2 tiles - 3 wide, 2 tall)
|
# Create door on BOTTOM wall of top room (3x2 tiles - 3 wide, 2 tall)
|
||||||
# Door is placed ON the wall, replacing the 2-tile tall wall
|
# Door is placed ON the wall, replacing the 2-tile tall wall
|
||||||
@@ -1057,52 +1286,6 @@ func _find_reachable_rooms(start_room: Dictionary, _all_rooms: Array, all_doors:
|
|||||||
|
|
||||||
return reachable
|
return reachable
|
||||||
|
|
||||||
func _add_hole_to_room(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator):
|
|
||||||
# Add a hole (minimum 9x9 tiles) somewhere in the room
|
|
||||||
# Holes should only be placed in the floor area (not in walls)
|
|
||||||
# Floor area is from room.x + 2 to room.x + room.w - 2, and room.y + 2 to room.y + room.h - 2
|
|
||||||
var floor_min_x = room.x + 2
|
|
||||||
var floor_min_y = room.y + 2
|
|
||||||
var floor_max_x = room.x + room.w - 2
|
|
||||||
var floor_max_y = room.y + room.h - 2
|
|
||||||
var floor_w = floor_max_x - floor_min_x
|
|
||||||
var floor_h = floor_max_y - floor_min_y
|
|
||||||
|
|
||||||
if floor_w < MIN_HOLE_SIZE or floor_h < MIN_HOLE_SIZE:
|
|
||||||
return # Room too small for hole
|
|
||||||
|
|
||||||
var hole_size = rng.randi_range(MIN_HOLE_SIZE, min(floor_w, floor_h, 12))
|
|
||||||
|
|
||||||
# Position hole within floor area (with 1 tile margin from floor edges)
|
|
||||||
var max_x = floor_max_x - hole_size
|
|
||||||
var max_y = floor_max_y - hole_size
|
|
||||||
|
|
||||||
if max_x < floor_min_x or max_y < floor_min_y:
|
|
||||||
return # Floor area too small for hole
|
|
||||||
|
|
||||||
var hole_x = rng.randi_range(floor_min_x, max_x)
|
|
||||||
var hole_y = rng.randi_range(floor_min_y, max_y)
|
|
||||||
|
|
||||||
# Create hole (back to wall) - use fallout inner corner/edge tiles; cleanup pass will refine edges
|
|
||||||
# Only create hole if the position is currently a floor tile
|
|
||||||
for x in range(hole_x, hole_x + hole_size):
|
|
||||||
for y in range(hole_y, hole_y + hole_size):
|
|
||||||
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
|
||||||
# Only create hole if it's currently a floor tile
|
|
||||||
if grid[x][y] == 1: # Floor
|
|
||||||
grid[x][y] = 0 # Wall
|
|
||||||
# Fallout corner tiles for hole corners; rest get center (cleanup will set edges)
|
|
||||||
if x == hole_x and y == hole_y:
|
|
||||||
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_LEFT
|
|
||||||
elif x == hole_x + hole_size - 1 and y == hole_y:
|
|
||||||
tile_grid[x][y] = FALLOUT_CORNER_INNER_UP_RIGHT
|
|
||||||
elif x == hole_x and y == hole_y + hole_size - 1:
|
|
||||||
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_LEFT
|
|
||||||
elif x == hole_x + hole_size - 1 and y == hole_y + hole_size - 1:
|
|
||||||
tile_grid[x][y] = FALLOUT_CORNER_INNER_DOWN_RIGHT
|
|
||||||
else:
|
|
||||||
tile_grid[x][y] = FALLOUT_CENTER
|
|
||||||
|
|
||||||
func _find_farthest_room(all_rooms: Array, start_index: int) -> int:
|
func _find_farthest_room(all_rooms: Array, start_index: int) -> int:
|
||||||
var start_room = all_rooms[start_index]
|
var start_room = all_rooms[start_index]
|
||||||
var max_distance = 0
|
var max_distance = 0
|
||||||
@@ -1119,6 +1302,171 @@ func _find_farthest_room(all_rooms: Array, start_index: int) -> int:
|
|||||||
|
|
||||||
return farthest_index
|
return farthest_index
|
||||||
|
|
||||||
|
func _find_largest_room_index(all_rooms: Array) -> int:
|
||||||
|
# Return index of the room with the largest area (w*h). Used for level 4 so boss gets the big room.
|
||||||
|
var best_index = 0
|
||||||
|
var best_area = all_rooms[0].w * all_rooms[0].h
|
||||||
|
for i in range(1, all_rooms.size()):
|
||||||
|
var area = all_rooms[i].w * all_rooms[i].h
|
||||||
|
if area > best_area:
|
||||||
|
best_area = area
|
||||||
|
best_index = i
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Largest room is index " + str(best_index) + " (size " + str(all_rooms[best_index].w) + "x" + str(all_rooms[best_index].h) + ", area " + str(best_area) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
return best_index
|
||||||
|
|
||||||
|
func _find_exit_room_after_boss_level4(all_rooms: Array, all_doors: Array, boss_room_index: int, start_room_index: int) -> int:
|
||||||
|
# Find a room that is adjacent to the boss room (connected by a door) to use as the exit room (stairs).
|
||||||
|
# Prefer a room that has only one connection (to the boss room). If none, fallback to boss room (stairs in same room).
|
||||||
|
var boss_room = all_rooms[boss_room_index]
|
||||||
|
var candidates = [] # {index, door_count}
|
||||||
|
for i in range(all_rooms.size()):
|
||||||
|
if i == boss_room_index or i == start_room_index:
|
||||||
|
continue
|
||||||
|
var room = all_rooms[i]
|
||||||
|
var doors_to_boss = 0
|
||||||
|
for door in all_doors:
|
||||||
|
var r1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
|
||||||
|
var r2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
|
||||||
|
var conn_boss = (r1 and r1.x == boss_room.x and r1.y == boss_room.y and r1.w == boss_room.w and r1.h == boss_room.h) or (r2 and r2.x == boss_room.x and r2.y == boss_room.y and r2.w == boss_room.w and r2.h == boss_room.h)
|
||||||
|
var conn_room = (r1 and r1.x == room.x and r1.y == room.y and r1.w == room.w and r1.h == room.h) or (r2 and r2.x == room.x and r2.y == room.y and r2.w == room.w and r2.h == room.h)
|
||||||
|
if conn_boss and conn_room:
|
||||||
|
doors_to_boss += 1
|
||||||
|
if doors_to_boss > 0:
|
||||||
|
var total_doors = 0
|
||||||
|
for door in all_doors:
|
||||||
|
var r1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
|
||||||
|
var r2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
|
||||||
|
if (r1 and r1.x == room.x and r1.y == room.y) or (r2 and r2.x == room.x and r2.y == room.y):
|
||||||
|
total_doors += 1
|
||||||
|
candidates.append({"index": i, "doors_to_boss": doors_to_boss, "total_doors": total_doors})
|
||||||
|
if candidates.is_empty():
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - No room adjacent to boss room, using boss room as exit", LogManager.CATEGORY_DUNGEON)
|
||||||
|
return boss_room_index
|
||||||
|
# Prefer room with only one door (only connected to boss)
|
||||||
|
candidates.sort_custom(func(a, b): return a.total_doors < b.total_doors)
|
||||||
|
var best = candidates[0].index
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Exit room after boss is index " + str(best), LogManager.CATEGORY_DUNGEON)
|
||||||
|
return best
|
||||||
|
|
||||||
|
func _room_equals(a: Dictionary, b: Dictionary) -> bool:
|
||||||
|
return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h
|
||||||
|
|
||||||
|
func _fill_door_tiles_as_wall(door: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i) -> void:
|
||||||
|
# Revert door and corridor tiles to wall (grid 0, generic wall tile) so we can "remove" the door.
|
||||||
|
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
|
||||||
|
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
|
||||||
|
if not r1 or not r2:
|
||||||
|
return
|
||||||
|
var door_dir = door.get("dir", "E")
|
||||||
|
var dx = door.get("x", 0)
|
||||||
|
var dy = door.get("y", 0)
|
||||||
|
var wall_tile = WALL_TOP_LOWER
|
||||||
|
if door_dir == "E":
|
||||||
|
# Horizontal: left door 2x3 at (dx, dy), right door 2x3 at (r2.x, dy), corridor one row at y=dy+1
|
||||||
|
# Left door tiles
|
||||||
|
for door_dx in range(2):
|
||||||
|
for door_dy in range(3):
|
||||||
|
var x = dx + door_dx
|
||||||
|
var y = dy + door_dy
|
||||||
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
grid[x][y] = 0
|
||||||
|
tile_grid[x][y] = wall_tile
|
||||||
|
# Right door tiles
|
||||||
|
for door_dx in range(2):
|
||||||
|
for door_dy in range(3):
|
||||||
|
var x = r2.x + door_dx
|
||||||
|
var y = dy + door_dy
|
||||||
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
grid[x][y] = 0
|
||||||
|
tile_grid[x][y] = wall_tile
|
||||||
|
# Corridor row (between the two room walls)
|
||||||
|
var cx_start = r1.x + r1.w
|
||||||
|
var cx_end = r2.x
|
||||||
|
for cx in range(cx_start, cx_end):
|
||||||
|
var cy = dy + 1
|
||||||
|
if cx >= 0 and cx < map_size.x and cy >= 0 and cy < map_size.y:
|
||||||
|
grid[cx][cy] = 0
|
||||||
|
tile_grid[cx][cy] = wall_tile
|
||||||
|
else:
|
||||||
|
# Vertical (S): top door 3x2 at (dx, dy), bottom door 3x2 at (dx, r2.y), corridor one column at x=dx+1
|
||||||
|
# Top door
|
||||||
|
for door_dx in range(3):
|
||||||
|
for door_dy in range(2):
|
||||||
|
var x = dx + door_dx
|
||||||
|
var y = dy + door_dy
|
||||||
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
grid[x][y] = 0
|
||||||
|
tile_grid[x][y] = wall_tile
|
||||||
|
# Bottom door
|
||||||
|
for door_dx in range(3):
|
||||||
|
for door_dy in range(2):
|
||||||
|
var x = dx + door_dx
|
||||||
|
var y = r2.y + door_dy
|
||||||
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
grid[x][y] = 0
|
||||||
|
tile_grid[x][y] = wall_tile
|
||||||
|
# Corridor column
|
||||||
|
var cy_start = r1.y + r1.h
|
||||||
|
var cy_end = r2.y
|
||||||
|
for cy in range(cy_start, cy_end):
|
||||||
|
var cx = dx + 1
|
||||||
|
if cx >= 0 and cx < map_size.x and cy >= 0 and cy < map_size.y:
|
||||||
|
grid[cx][cy] = 0
|
||||||
|
tile_grid[cx][cy] = wall_tile
|
||||||
|
|
||||||
|
func _remove_doors_from_exit_room_except_boss(all_rooms: Array, all_doors: Array, grid: Array, tile_grid: Array, map_size: Vector2i, exit_room_index: int, boss_room_index: int) -> void:
|
||||||
|
# Level 4: exit room should ideally only connect to boss. We remove doors that connect exit to a non-boss room
|
||||||
|
# ONLY when that other room has at least one other door (so we never isolate a room - e.g. entrance must stay connected).
|
||||||
|
var to_remove: Array = []
|
||||||
|
# Count doors per room (how many doors each room index has)
|
||||||
|
var door_count: Array = []
|
||||||
|
door_count.resize(all_rooms.size())
|
||||||
|
for j in range(all_rooms.size()):
|
||||||
|
door_count[j] = 0
|
||||||
|
for door in all_doors:
|
||||||
|
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
|
||||||
|
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
|
||||||
|
if not r1 or not r2:
|
||||||
|
continue
|
||||||
|
for j in range(all_rooms.size()):
|
||||||
|
if _room_equals(r1, all_rooms[j]):
|
||||||
|
door_count[j] += 1
|
||||||
|
if _room_equals(r2, all_rooms[j]):
|
||||||
|
door_count[j] += 1
|
||||||
|
for i in range(all_doors.size()):
|
||||||
|
var door = all_doors[i]
|
||||||
|
var r1 = door.room1 if ("room1" in door and door.room1 is Dictionary) else null
|
||||||
|
var r2 = door.room2 if ("room2" in door and door.room2 is Dictionary) else null
|
||||||
|
if not r1 or not r2:
|
||||||
|
continue
|
||||||
|
var r1_idx = -1
|
||||||
|
var r2_idx = -1
|
||||||
|
for j in range(all_rooms.size()):
|
||||||
|
if _room_equals(r1, all_rooms[j]):
|
||||||
|
r1_idx = j
|
||||||
|
if _room_equals(r2, all_rooms[j]):
|
||||||
|
r2_idx = j
|
||||||
|
var is_boss_exit_door = (r1_idx == boss_room_index and r2_idx == exit_room_index) or (r1_idx == exit_room_index and r2_idx == boss_room_index)
|
||||||
|
if is_boss_exit_door:
|
||||||
|
continue
|
||||||
|
var door_has_exit = (r1_idx == exit_room_index or r2_idx == exit_room_index)
|
||||||
|
if not door_has_exit:
|
||||||
|
continue
|
||||||
|
# Other room (the one that is not exit) must have more than 1 door total, else we would isolate it
|
||||||
|
var other_idx = r2_idx if r1_idx == exit_room_index else r1_idx
|
||||||
|
if other_idx < 0 or other_idx >= door_count.size():
|
||||||
|
continue
|
||||||
|
if door_count[other_idx] <= 1:
|
||||||
|
continue # Do NOT remove: would isolate that room (e.g. only path to boss)
|
||||||
|
to_remove.append(i)
|
||||||
|
# Remove from end so indices stay valid
|
||||||
|
for i in range(to_remove.size() - 1, -1, -1):
|
||||||
|
var idx = to_remove[i]
|
||||||
|
_fill_door_tiles_as_wall(all_doors[idx], grid, tile_grid, map_size)
|
||||||
|
all_doors.remove_at(idx)
|
||||||
|
if to_remove.size() > 0:
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Removed " + str(to_remove.size()) + " door(s) from exit room (kept doors that would have isolated a room)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
func _find_farthest_room_from_list(all_rooms: Array, start_index: int, reachable_rooms: Array) -> int:
|
func _find_farthest_room_from_list(all_rooms: Array, start_index: int, reachable_rooms: Array) -> int:
|
||||||
# Find the farthest room from the start room, but only from the reachable rooms list
|
# Find the farthest room from the start room, but only from the reachable rooms list
|
||||||
var start_room = all_rooms[start_index]
|
var start_room = all_rooms[start_index]
|
||||||
@@ -1462,9 +1810,11 @@ func _is_tile_blocked_for_fallout(x: int, y: int, _grid: Array, _map_size: Vecto
|
|||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator):
|
func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator, boss_room: Dictionary = {}, dungeon_level: int = 1):
|
||||||
# Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, or in start room
|
# Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, start room, or boss room. Level 4: much less fallout.
|
||||||
const CHANCE = 0.08
|
var CHANCE = 0.08
|
||||||
|
if dungeon_level == 4:
|
||||||
|
CHANCE = 0.02 # Level 4: only a light sprinkle, no insanely much
|
||||||
for x in range(map_size.x):
|
for x in range(map_size.x):
|
||||||
for y in range(map_size.y):
|
for y in range(map_size.y):
|
||||||
if grid[x][y] != 1:
|
if grid[x][y] != 1:
|
||||||
@@ -1473,6 +1823,8 @@ func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, al
|
|||||||
continue
|
continue
|
||||||
if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room):
|
if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room):
|
||||||
continue # No fallout tiles in start room
|
continue # No fallout tiles in start room
|
||||||
|
if not boss_room.is_empty() and _is_tile_in_room_interior(x, y, boss_room):
|
||||||
|
continue # No fallout tiles in boss room (level 4)
|
||||||
if rng.randf() >= CHANCE:
|
if rng.randf() >= CHANCE:
|
||||||
continue
|
continue
|
||||||
tile_grid[x][y] = _get_fallout_tile_for_floor(grid, map_size, x, y)
|
tile_grid[x][y] = _get_fallout_tile_for_floor(grid, map_size, x, y)
|
||||||
@@ -1483,6 +1835,14 @@ func _is_fallout_atlas(tile: Vector2i) -> bool:
|
|||||||
or tile == FALLOUT_INNER_RIGHT or tile == FALLOUT_INNER_DOWN_RIGHT or tile == FALLOUT_INNER_DOWN or tile == FALLOUT_INNER_DOWN_LEFT or tile == FALLOUT_INNER_LEFT \
|
or tile == FALLOUT_INNER_RIGHT or tile == FALLOUT_INNER_DOWN_RIGHT or tile == FALLOUT_INNER_DOWN or tile == FALLOUT_INNER_DOWN_LEFT or tile == FALLOUT_INNER_LEFT \
|
||||||
or tile == FALLOUT_CORNER_INNER_UP_LEFT or tile == FALLOUT_CORNER_INNER_UP_RIGHT or tile == FALLOUT_CORNER_INNER_DOWN_LEFT or tile == FALLOUT_CORNER_INNER_DOWN_RIGHT
|
or tile == FALLOUT_CORNER_INNER_UP_LEFT or tile == FALLOUT_CORNER_INNER_UP_RIGHT or tile == FALLOUT_CORNER_INNER_DOWN_LEFT or tile == FALLOUT_CORNER_INNER_DOWN_RIGHT
|
||||||
|
|
||||||
|
func _clear_cracked_on_fallout(tile_grid: Array, cracked_tile_grid: Array, map_size: Vector2i):
|
||||||
|
# Cracked floors must only show on non-fallout tiles; clear cracked wherever there is fallout
|
||||||
|
for x in range(map_size.x):
|
||||||
|
for y in range(map_size.y):
|
||||||
|
if x < cracked_tile_grid.size() and y < cracked_tile_grid[x].size():
|
||||||
|
if _is_fallout_atlas(tile_grid[x][y]):
|
||||||
|
cracked_tile_grid[x][y] = false
|
||||||
|
|
||||||
func _clear_decorated_on_fallout(tile_grid: Array, decorated_tile_grid: Array, map_size: Vector2i):
|
func _clear_decorated_on_fallout(tile_grid: Array, decorated_tile_grid: Array, map_size: Vector2i):
|
||||||
for x in range(map_size.x):
|
for x in range(map_size.x):
|
||||||
for y in range(map_size.y):
|
for y in range(map_size.y):
|
||||||
@@ -2096,8 +2456,9 @@ func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, m
|
|||||||
LogManager.log("DungeonGenerator: Force placed " + str(stairs_dir) + " stairs at tile (" + str(stairs_data.x) + "," + str(stairs_data.y) + ") world pos: " + str(stairs_data.world_pos), LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Force placed " + str(stairs_dir) + " stairs at tile (" + str(stairs_data.x) + "," + str(stairs_data.y) + ") world pos: " + str(stairs_data.world_pos), LogManager.CATEGORY_DUNGEON)
|
||||||
return stairs_data
|
return stairs_data
|
||||||
|
|
||||||
func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, all_enemies: Array, rng: RandomNumberGenerator, room_puzzle_data: Dictionary = {}) -> Array:
|
func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, all_enemies: Array, rng: RandomNumberGenerator, room_puzzle_data: Dictionary = {}, exclude_switch_tiles: Array = []) -> Array:
|
||||||
# Place interactable objects in a room
|
# Place interactable objects in a room
|
||||||
|
# exclude_switch_tiles: Array of Vector2i (tile_x, tile_y) to never place objects on (e.g. pillar switch tile - would pre-solve puzzle)
|
||||||
# Small rooms (7-8 tiles): 0-1 objects
|
# Small rooms (7-8 tiles): 0-1 objects
|
||||||
# Medium rooms (9-10 tiles): 0-3 objects
|
# Medium rooms (9-10 tiles): 0-3 objects
|
||||||
# Large rooms (11-12 tiles): 0-8 objects
|
# Large rooms (11-12 tiles): 0-8 objects
|
||||||
@@ -2182,6 +2543,14 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
|
|||||||
for x in range(min_x, max_x + 1): # +1 because range is exclusive at end
|
for x in range(min_x, max_x + 1): # +1 because range is exclusive at end
|
||||||
for y in range(min_y, max_y + 1):
|
for y in range(min_y, max_y + 1):
|
||||||
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
# Never place objects on a floor switch tile (pillar would pre-solve the puzzle)
|
||||||
|
var is_switch_tile = false
|
||||||
|
for st in exclude_switch_tiles:
|
||||||
|
if st is Vector2i and st.x == x and st.y == y:
|
||||||
|
is_switch_tile = true
|
||||||
|
break
|
||||||
|
if is_switch_tile:
|
||||||
|
continue
|
||||||
# Check if it's a floor tile
|
# Check if it's a floor tile
|
||||||
if grid[x][y] == 1: # Floor
|
if grid[x][y] == 1: # Floor
|
||||||
# CRITICAL: Interactable objects are 16x16 pixels with origin at (0,0) (top-left)
|
# CRITICAL: Interactable objects are 16x16 pixels with origin at (0,0) (top-left)
|
||||||
@@ -2207,6 +2576,13 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
|
|||||||
for x in range(min_x, max_x + 1):
|
for x in range(min_x, max_x + 1):
|
||||||
for y in range(min_y, max_y + 1):
|
for y in range(min_y, max_y + 1):
|
||||||
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||||
|
var is_switch_tile = false
|
||||||
|
for st in exclude_switch_tiles:
|
||||||
|
if st is Vector2i and st.x == x and st.y == y:
|
||||||
|
is_switch_tile = true
|
||||||
|
break
|
||||||
|
if is_switch_tile:
|
||||||
|
continue
|
||||||
if grid[x][y] == 1: # Floor
|
if grid[x][y] == 1: # Floor
|
||||||
var world_x = x * tile_size + 8
|
var world_x = x * tile_size + 8
|
||||||
var world_y = y * tile_size + 8
|
var world_y = y * tile_size + 8
|
||||||
@@ -2252,26 +2628,31 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, tile_gri
|
|||||||
# Skip Pillar type for remaining objects (already placed one)
|
# Skip Pillar type for remaining objects (already placed one)
|
||||||
while object_type_data.type == "Pillar":
|
while object_type_data.type == "Pillar":
|
||||||
object_type_data = object_types[rng.randi() % object_types.size()]
|
object_type_data = object_types[rng.randi() % object_types.size()]
|
||||||
|
var obj_entry = {
|
||||||
objects.append({
|
|
||||||
"type": object_type_data.type,
|
"type": object_type_data.type,
|
||||||
"setup_function": object_type_data.setup,
|
"setup_function": object_type_data.setup,
|
||||||
"position": valid_positions[positions_index],
|
"position": valid_positions[positions_index],
|
||||||
"room": room
|
"room": room
|
||||||
})
|
}
|
||||||
|
if object_type_data.type == "Chest" and rng.randf() < 0.4:
|
||||||
|
obj_entry["hidden"] = true
|
||||||
|
objects.append(obj_entry)
|
||||||
positions_index += 1
|
positions_index += 1
|
||||||
else:
|
else:
|
||||||
# Normal placement: no pillar requirement
|
# Normal placement: no pillar requirement
|
||||||
for i in range(min(num_objects, valid_positions.size())):
|
for i in range(min(num_objects, valid_positions.size())):
|
||||||
var object_type_data = object_types[rng.randi() % object_types.size()]
|
var object_type_data = object_types[rng.randi() % object_types.size()]
|
||||||
var position = valid_positions[i]
|
var position = valid_positions[i]
|
||||||
|
var obj_entry = {
|
||||||
objects.append({
|
|
||||||
"type": object_type_data.type,
|
"type": object_type_data.type,
|
||||||
"setup_function": object_type_data.setup,
|
"setup_function": object_type_data.setup,
|
||||||
"position": position,
|
"position": position,
|
||||||
"room": room
|
"room": room
|
||||||
})
|
}
|
||||||
|
# Chests can be hidden (invisible until perception roll detects them) ~40% chance so SfxSecretFound is heard
|
||||||
|
if object_type_data.type == "Chest" and rng.randf() < 0.4:
|
||||||
|
obj_entry["hidden"] = true
|
||||||
|
objects.append(obj_entry)
|
||||||
|
|
||||||
# If an interactable spawns on a fallout tile, replace that tile with normal floor
|
# If an interactable spawns on a fallout tile, replace that tile with normal floor
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
@@ -2414,7 +2795,7 @@ func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_room
|
|||||||
|
|
||||||
return rooms_before_door
|
return rooms_before_door
|
||||||
|
|
||||||
func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, start_room_index: int, exit_room_index: int) -> Dictionary:
|
func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator, start_room_index: int, exit_room_index: int, level: int = 1) -> Dictionary:
|
||||||
# Place blocking doors on existing tile doors
|
# Place blocking doors on existing tile doors
|
||||||
# Returns array of blocking door data dictionaries
|
# Returns array of blocking door data dictionaries
|
||||||
var blocking_doors = []
|
var blocking_doors = []
|
||||||
@@ -2430,14 +2811,40 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
|
|||||||
# Track which rooms have puzzles and which doors are already assigned
|
# Track which rooms have puzzles and which doors are already assigned
|
||||||
var _rooms_with_puzzles = {} # room -> true
|
var _rooms_with_puzzles = {} # room -> true
|
||||||
var assigned_doors = [] # Doors already assigned to a room puzzle
|
var assigned_doors = [] # Doors already assigned to a room puzzle
|
||||||
var room_puzzle_data = {} # room -> {type: "switch" or "enemy", doors: []}
|
var room_puzzle_data = {} # room -> {type: "switch" or "enemy" or "boss", doors: []}
|
||||||
|
|
||||||
|
# STEP 0: Level 4 - BOSS room (not exit room): door(s) leading INTO boss room close and require boss defeat; exit is in a different room after boss
|
||||||
|
if level == 4:
|
||||||
|
var boss_room = null
|
||||||
|
for room in all_rooms:
|
||||||
|
if room.get("modifiers") is Array:
|
||||||
|
for mod in room.modifiers:
|
||||||
|
if mod is Dictionary and mod.get("type") == "BOSS":
|
||||||
|
boss_room = room
|
||||||
|
break
|
||||||
|
if boss_room != null:
|
||||||
|
break
|
||||||
|
if boss_room != null:
|
||||||
|
# All doors connected to the boss room (either side) must get blocking doors
|
||||||
|
var doors_into_boss = []
|
||||||
|
for door in all_doors:
|
||||||
|
var door_room1 = door.room1 if ("room1" in door and door.room1 and not door.room1.is_empty()) else null
|
||||||
|
var door_room2 = door.room2 if ("room2" in door and door.room2 and not door.room2.is_empty()) else null
|
||||||
|
var conn_boss = (door_room1 and door_room1.x == boss_room.x and door_room1.y == boss_room.y and door_room1.w == boss_room.w and door_room1.h == boss_room.h) or (door_room2 and door_room2.x == boss_room.x and door_room2.y == boss_room.y and door_room2.w == boss_room.w and door_room2.h == boss_room.h)
|
||||||
|
if conn_boss:
|
||||||
|
doors_into_boss.append(door)
|
||||||
|
if doors_into_boss.size() > 0:
|
||||||
|
room_puzzle_data[boss_room] = {"type": "boss", "doors": doors_into_boss}
|
||||||
|
for d in doors_into_boss:
|
||||||
|
assigned_doors.append(d)
|
||||||
|
LogManager.log("DungeonGenerator: Level 4 - Boss room has " + str(doors_into_boss.size()) + " door(s) that will close behind player", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# STEP 1: For each room (except start/exit), randomly decide if it has a door-puzzle
|
# STEP 1: For each room (except start/exit), randomly decide if it has a door-puzzle
|
||||||
var puzzle_room_chance = 0.4 # 40% chance per room
|
var puzzle_room_chance = 0.4 # 40% chance per room
|
||||||
LogManager.log("DungeonGenerator: Assigning puzzles to rooms (" + str(all_rooms.size()) + " total rooms, excluding start/exit)", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Assigning puzzles to rooms (" + str(all_rooms.size()) + " total rooms, excluding start/exit)", LogManager.CATEGORY_DUNGEON)
|
||||||
for i in range(all_rooms.size()):
|
for i in range(all_rooms.size()):
|
||||||
if i == start_room_index or i == exit_room_index:
|
if i == start_room_index or i == exit_room_index:
|
||||||
continue # Skip start and exit rooms
|
continue # Skip start and exit rooms (exit already handled as boss on level 4)
|
||||||
|
|
||||||
var room = all_rooms[i]
|
var room = all_rooms[i]
|
||||||
|
|
||||||
@@ -2524,16 +2931,18 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
|
|||||||
room_index = j
|
room_index = j
|
||||||
break
|
break
|
||||||
|
|
||||||
if room_index == start_room_index or room_index == exit_room_index:
|
var puzzle_info = room_puzzle_data[room]
|
||||||
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start/exit room! Skipping.", LogManager.CATEGORY_DUNGEON)
|
if room_index == start_room_index:
|
||||||
|
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start room! Skipping.", LogManager.CATEGORY_DUNGEON)
|
||||||
|
continue
|
||||||
|
if room_index == exit_room_index and puzzle_info.type != "boss":
|
||||||
|
LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for exit room (non-boss)! Skipping.", LogManager.CATEGORY_DUNGEON)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check)
|
# CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check)
|
||||||
if room_index == -1:
|
if room_index == -1:
|
||||||
LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") not found in all_rooms! Skipping.", LogManager.CATEGORY_DUNGEON)
|
LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") not found in all_rooms! Skipping.", LogManager.CATEGORY_DUNGEON)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var puzzle_info = room_puzzle_data[room]
|
|
||||||
var doors_in_room = puzzle_info.doors # Doors that are IN this puzzle room (lead OUT OF it)
|
var doors_in_room = puzzle_info.doors # Doors that are IN this puzzle room (lead OUT OF it)
|
||||||
var puzzle_type = puzzle_info.type
|
var puzzle_type = puzzle_info.type
|
||||||
|
|
||||||
@@ -2593,6 +3002,15 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
|
|||||||
LogManager.log("DungeonGenerator: Created enemy spawner puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - spawner at " + str(spawner_data.position), LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Created enemy spawner puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - spawner at " + str(spawner_data.position), LogManager.CATEGORY_DUNGEON)
|
||||||
else:
|
else:
|
||||||
LogManager.log("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON)
|
||||||
|
elif puzzle_type == "boss":
|
||||||
|
# Boss room: no switch or spawner; door closes on entry and opens when boss (pre-placed enemy) is defeated
|
||||||
|
puzzle_element_created = true
|
||||||
|
puzzle_element_data = {
|
||||||
|
"type": "boss",
|
||||||
|
"blocking_room": room
|
||||||
|
}
|
||||||
|
door_type = "StoneDoor" # Boss room always uses StoneDoor
|
||||||
|
LogManager.log("DungeonGenerator: Created boss room puzzle for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# CRITICAL SAFETY CHECK: Only create blocking doors if puzzle element was successfully created
|
# CRITICAL SAFETY CHECK: Only create blocking doors if puzzle element was successfully created
|
||||||
if not puzzle_element_created:
|
if not puzzle_element_created:
|
||||||
@@ -2705,8 +3123,10 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
|
|||||||
"puzzle_type": puzzle_type # "switch_walk", "switch_pillar", or "enemy"
|
"puzzle_type": puzzle_type # "switch_walk", "switch_pillar", or "enemy"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Store puzzle room as room1 for blocking doors
|
# Store puzzle room as room1 for blocking doors; room2 is the other room (so room_trigger finds door in puzzle room)
|
||||||
door_data.original_room1 = room # Puzzle room is always room1 for blocking doors
|
door_data.original_room1 = room # Puzzle room is always room1 for blocking doors
|
||||||
|
if puzzle_is_room2 and ("room1" in door and door.room1 and not door.room1.is_empty()):
|
||||||
|
door_data.original_room2 = door.room1 # Room you're coming from (so door.room1/room2 are correct in game_world)
|
||||||
|
|
||||||
LogManager.log("DungeonGenerator: Creating blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open_tile: (" + str(open_tile_x) + "," + str(open_tile_y) + ")", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Creating blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open_tile: (" + str(open_tile_x) + "," + str(open_tile_y) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
@@ -2738,6 +3158,11 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_
|
|||||||
door_data.requires_enemies = true
|
door_data.requires_enemies = true
|
||||||
door_has_valid_puzzle = true
|
door_has_valid_puzzle = true
|
||||||
LogManager.log("DungeonGenerator: Added enemy spawner data to door - spawner at (" + str(puzzle_element_data.spawner_data.tile_x) + ", " + str(puzzle_element_data.spawner_data.tile_y) + ")", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("DungeonGenerator: Added enemy spawner data to door - spawner at (" + str(puzzle_element_data.spawner_data.tile_x) + ", " + str(puzzle_element_data.spawner_data.tile_y) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
elif puzzle_element_data.has("type") and puzzle_element_data.type == "boss":
|
||||||
|
door_data.requires_enemies = true
|
||||||
|
door_data.boss_room_door = true # Door checks all enemies in room (pre-placed boss), not just spawner
|
||||||
|
door_has_valid_puzzle = true
|
||||||
|
LogManager.log("DungeonGenerator: Added boss room door - requires_enemies (boss)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# CRITICAL SAFETY CHECK: Only add door to blocking doors if it has valid puzzle element
|
# CRITICAL SAFETY CHECK: Only add door to blocking doors if it has valid puzzle element
|
||||||
if not door_has_valid_puzzle:
|
if not door_has_valid_puzzle:
|
||||||
@@ -3049,8 +3474,8 @@ func _determine_door_direction_for_puzzle_room(door: Dictionary, puzzle_room: Di
|
|||||||
else:
|
else:
|
||||||
return "Right" # Door is right of puzzle room center - door is on right wall
|
return "Right" # Door is right of puzzle room center - door is on right wall
|
||||||
|
|
||||||
func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_index: int, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Array:
|
func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_index: int, grid: Array, tile_grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Array:
|
||||||
# Place 1-2 traps in the dungeon (not in start or exit rooms)
|
# Place 1-2 traps in the dungeon (not in start or exit rooms; never on fallout tiles)
|
||||||
var traps = []
|
var traps = []
|
||||||
var tile_size = 16
|
var tile_size = 16
|
||||||
|
|
||||||
@@ -3093,9 +3518,13 @@ func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_
|
|||||||
var world_x = room.x + local_x
|
var world_x = room.x + local_x
|
||||||
var world_y = room.y + local_y
|
var world_y = room.y + local_y
|
||||||
|
|
||||||
# Check if position is valid (floor tile, not blocked)
|
# Check if position is valid (floor tile, not blocked, not fallout)
|
||||||
if world_x >= 0 and world_x < map_size.x and world_y >= 0 and world_y < map_size.y:
|
if world_x >= 0 and world_x < map_size.x and world_y >= 0 and world_y < map_size.y:
|
||||||
if grid[world_x][world_y] == 1: # Floor tile
|
if grid[world_x][world_y] == 1: # Floor tile
|
||||||
|
# Never place trap on a fallout tile
|
||||||
|
if world_x < tile_grid.size() and world_y < tile_grid[world_x].size() and _is_fallout_atlas(tile_grid[world_x][world_y]):
|
||||||
|
attempts -= 1
|
||||||
|
continue
|
||||||
# Check if position is not too close to door (avoid blocking doorways)
|
# Check if position is not too close to door (avoid blocking doorways)
|
||||||
var too_close_to_door = false
|
var too_close_to_door = false
|
||||||
# Simplified check - just ensure we're not right at door position
|
# Simplified check - just ensure we're not right at door position
|
||||||
|
|||||||
@@ -54,9 +54,18 @@ var anim_speed: float = 0.15 # Seconds per frame
|
|||||||
@onready var collision_shape = get_node_or_null("CollisionShape2D")
|
@onready var collision_shape = get_node_or_null("CollisionShape2D")
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
|
# Capture max_health set by spawn data (game_world sets it before add_child); _initialize_character_stats overwrites it
|
||||||
|
var spawn_max_health = max_health
|
||||||
# Initialize CharacterStats for RPG system
|
# Initialize CharacterStats for RPG system
|
||||||
_initialize_character_stats()
|
_initialize_character_stats()
|
||||||
|
# If spawn/scene set a higher max_health (e.g. boss with 1200), apply it to character_stats so take_damage uses it
|
||||||
|
if spawn_max_health > 0 and spawn_max_health > character_stats.maxhp:
|
||||||
|
var base_max = (character_stats.baseStats.end + character_stats.get_pass("end")) * 3.0 + character_stats.get_pass("maxhp")
|
||||||
|
character_stats.bonusmaxhp = spawn_max_health - base_max
|
||||||
|
character_stats.hp = character_stats.maxhp
|
||||||
|
max_health = character_stats.maxhp
|
||||||
|
current_health = character_stats.hp
|
||||||
|
else:
|
||||||
current_health = max_health
|
current_health = max_health
|
||||||
add_to_group("enemy")
|
add_to_group("enemy")
|
||||||
|
|
||||||
@@ -73,6 +82,19 @@ func _ready():
|
|||||||
# Walls are on layer 7 (bit 6 = 64), not layer 4!
|
# Walls are on layer 7 (bit 6 = 64), not layer 4!
|
||||||
collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
|
collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
|
||||||
|
|
||||||
|
# Ensure SfxFallout exists for all enemies (LA_Enemy_Fall.wav when falling into fallout)
|
||||||
|
var fall_sfx = get_node_or_null("SfxFallout")
|
||||||
|
if fall_sfx == null:
|
||||||
|
fall_sfx = AudioStreamPlayer2D.new()
|
||||||
|
fall_sfx.name = "SfxFallout"
|
||||||
|
add_child(fall_sfx)
|
||||||
|
var stream = load("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream
|
||||||
|
if stream:
|
||||||
|
fall_sfx.stream = stream
|
||||||
|
fall_sfx.attenuation = 7.0
|
||||||
|
fall_sfx.panning_strength = 1.14
|
||||||
|
fall_sfx.bus = "Sfx"
|
||||||
|
|
||||||
# Initialize CharacterStats for this enemy
|
# Initialize CharacterStats for this enemy
|
||||||
# Override in subclasses to set specific baseStats
|
# Override in subclasses to set specific baseStats
|
||||||
func _initialize_character_stats():
|
func _initialize_character_stats():
|
||||||
@@ -127,69 +149,8 @@ func _physics_process(delta):
|
|||||||
burn_sprite.set_meta("burn_animation_timer", anim_timer)
|
burn_sprite.set_meta("burn_animation_timer", anim_timer)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fallout: humanoid sinks like player (FALL anim) then dies; slime rotates 45 + scale then dies
|
# Fallout: humanoid sinks like player; slime/spider/rat rotate 45 + scale then die. Subclasses that override _physics_process (e.g. spider) must call _check_and_handle_fallout(delta) and return if true.
|
||||||
if fallout_state:
|
if _check_and_handle_fallout(delta):
|
||||||
velocity = Vector2.ZERO
|
|
||||||
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
|
|
||||||
if fallout_scale_progress <= 0.0:
|
|
||||||
died_from_fallout = true
|
|
||||||
call_deferred("_die")
|
|
||||||
return
|
|
||||||
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
|
|
||||||
if has_method("_set_animation"):
|
|
||||||
_set_animation("FALL")
|
|
||||||
move_and_slide()
|
|
||||||
return
|
|
||||||
if fallout_defeat_started:
|
|
||||||
velocity = Vector2.ZERO
|
|
||||||
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
|
|
||||||
if fallout_scale_progress <= 0.0:
|
|
||||||
died_from_fallout = true
|
|
||||||
call_deferred("_die")
|
|
||||||
return
|
|
||||||
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
|
|
||||||
rotation = deg_to_rad(45.0)
|
|
||||||
move_and_slide()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Only ground enemies (position_z <= 0) can fall into fallout; bat has position_z 1 and ignores it
|
|
||||||
# Humanoid: use 16x16 box check (like player) so any part on fallout triggers; drag toward center then sink
|
|
||||||
var gw = get_tree().get_first_node_in_group("game_world")
|
|
||||||
var on_fallout = false
|
|
||||||
if position_z <= 0.0 and gw:
|
|
||||||
if "humanoid_type" in self and gw.has_method("_is_player_box_on_fallout_tile"):
|
|
||||||
on_fallout = gw._is_player_box_on_fallout_tile(global_position, 8.0)
|
|
||||||
elif gw.has_method("_is_position_on_fallout_tile"):
|
|
||||||
on_fallout = gw._is_position_on_fallout_tile(global_position)
|
|
||||||
if on_fallout:
|
|
||||||
if "humanoid_type" in self:
|
|
||||||
# Humanoid: drag toward tile center (quicksand pull) then sink when at center
|
|
||||||
var tile_center = gw._get_closest_fallout_tile_center(global_position) if gw.has_method("_get_closest_fallout_tile_center") else (gw._get_tile_center_at(global_position) if gw.has_method("_get_tile_center_at") else global_position)
|
|
||||||
var dist_to_center = global_position.distance_to(tile_center)
|
|
||||||
if dist_to_center < FALLOUT_CENTER_THRESHOLD:
|
|
||||||
global_position = tile_center
|
|
||||||
fallout_state = true
|
|
||||||
fallout_scale_progress = 1.0
|
|
||||||
velocity = Vector2.ZERO
|
|
||||||
if has_method("_set_animation"):
|
|
||||||
_set_animation("FALL")
|
|
||||||
if has_node("SfxFallout"):
|
|
||||||
$SfxFallout.play()
|
|
||||||
else:
|
|
||||||
# Drag toward center (quicksand pull)
|
|
||||||
var dir = (tile_center - global_position).normalized()
|
|
||||||
const FALLOUT_DRAG_STRENGTH: float = 820.0
|
|
||||||
velocity = dir * FALLOUT_DRAG_STRENGTH * get_process_delta_time()
|
|
||||||
if has_method("_set_animation"):
|
|
||||||
_set_animation("RUN")
|
|
||||||
move_and_slide()
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# Slime-like: rotate 45 and scale down then die
|
|
||||||
fallout_defeat_started = true
|
|
||||||
fallout_scale_progress = 1.0
|
|
||||||
rotation = deg_to_rad(45.0)
|
|
||||||
move_and_slide()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update attack timer
|
# Update attack timer
|
||||||
@@ -211,8 +172,9 @@ func _physics_process(delta):
|
|||||||
if not is_knocked_back:
|
if not is_knocked_back:
|
||||||
_ai_behavior(delta)
|
_ai_behavior(delta)
|
||||||
|
|
||||||
# Slime, rat, humanoid: try to avoid stepping onto fallout (position_z <= 0 = ground enemies only; bat has position_z 1)
|
# All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back)
|
||||||
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and velocity.length_squared() > 1.0:
|
# Skip avoidance during knockback so thrown objects (barrel, box) can knock enemies into fallout tiles
|
||||||
|
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and not is_knocked_back and velocity.length_squared() > 1.0:
|
||||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
if game_world and game_world.has_method("_is_position_on_fallout_tile"):
|
if game_world and game_world.has_method("_is_position_on_fallout_tile"):
|
||||||
var step = velocity.normalized() * 18.0
|
var step = velocity.normalized() * 18.0
|
||||||
@@ -257,6 +219,10 @@ func _physics_process(delta):
|
|||||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
if game_world and game_world.has_method("_sync_enemy_damage_visual"):
|
if game_world and game_world.has_method("_sync_enemy_damage_visual"):
|
||||||
game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, global_position, false])
|
game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, global_position, false])
|
||||||
|
# Sync boss health so joiner's HUD bar updates
|
||||||
|
if has_meta("is_boss") and get_meta("is_boss") and game_world and game_world.has_method("_rpc_to_ready_peers") and game_world.has_method("_sync_boss_health"):
|
||||||
|
var max_hp = character_stats.maxhp if character_stats else max_health
|
||||||
|
game_world._rpc_to_ready_peers("_sync_boss_health", [enemy_name, enemy_index, current_health, max_hp])
|
||||||
|
|
||||||
# Animate burn visual if it's a sprite (only on authority/server)
|
# Animate burn visual if it's a sprite (only on authority/server)
|
||||||
if is_multiplayer_authority() and burn_debuff_visual and is_instance_valid(burn_debuff_visual):
|
if is_multiplayer_authority() and burn_debuff_visual and is_instance_valid(burn_debuff_visual):
|
||||||
@@ -278,26 +244,93 @@ func _physics_process(delta):
|
|||||||
burn_damage_timer = 0.0
|
burn_damage_timer = 0.0
|
||||||
_remove_burn_debuff()
|
_remove_burn_debuff()
|
||||||
|
|
||||||
# Sync position and animation to clients (only server sends)
|
# Sync position and animation to clients (only server sends). Also call from subclasses that override _physics_process (e.g. spider).
|
||||||
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
|
_send_position_sync_to_clients()
|
||||||
# Get state value if enemy has a state variable (for bats/slimes)
|
|
||||||
|
# Call this from base _physics_process or from subclasses that override _physics_process (e.g. enemy_spider) so position sync is always sent.
|
||||||
|
func _send_position_sync_to_clients() -> void:
|
||||||
|
if not multiplayer.has_multiplayer_peer() or not is_multiplayer_authority():
|
||||||
|
return
|
||||||
var state_val = -1
|
var state_val = -1
|
||||||
if "state" in self:
|
if "state" in self:
|
||||||
state_val = get("state") as int
|
state_val = get("state") as int
|
||||||
# Only send RPC if we're in the scene tree
|
if not is_inside_tree():
|
||||||
if is_inside_tree():
|
return
|
||||||
# Get enemy name/index for identification
|
|
||||||
var enemy_name = name
|
var enemy_name = name
|
||||||
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
||||||
|
|
||||||
# Use game_world to send RPC instead of rpc() on node instance
|
|
||||||
# This avoids node path resolution issues when clients haven't spawned yet
|
|
||||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
if game_world and game_world.has_method("_sync_enemy_position"):
|
if game_world and game_world.has_method("_broadcast_enemy_position"):
|
||||||
# Send via game_world using enemy name/index and position for identification
|
game_world._broadcast_enemy_position(enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val)
|
||||||
|
elif game_world and game_world.has_method("_sync_enemy_position"):
|
||||||
game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val])
|
game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val])
|
||||||
# Removed fallback rpc() call - it causes node path resolution errors
|
|
||||||
# If game_world is not available, skip sync (will sync next frame)
|
# Returns true if we're in a fallout state and caller should return (used by base _physics_process and by spider which overrides _physics_process).
|
||||||
|
func _check_and_handle_fallout(delta: float) -> bool:
|
||||||
|
if fallout_state:
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
|
||||||
|
if fallout_scale_progress <= 0.0:
|
||||||
|
died_from_fallout = true
|
||||||
|
call_deferred("_die")
|
||||||
|
return true
|
||||||
|
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
|
||||||
|
if "humanoid_type" in self:
|
||||||
|
current_direction = Direction.DOWN
|
||||||
|
rotation = deg_to_rad(45.0)
|
||||||
|
if has_method("_set_animation"):
|
||||||
|
_set_animation("FALL")
|
||||||
|
move_and_slide()
|
||||||
|
return true
|
||||||
|
if fallout_defeat_started:
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
fallout_scale_progress -= delta / FALLOUT_SINK_DURATION
|
||||||
|
if fallout_scale_progress <= 0.0:
|
||||||
|
died_from_fallout = true
|
||||||
|
call_deferred("_die")
|
||||||
|
return true
|
||||||
|
scale = Vector2.ONE * max(0.0, fallout_scale_progress)
|
||||||
|
rotation = deg_to_rad(45.0)
|
||||||
|
move_and_slide()
|
||||||
|
return true
|
||||||
|
var gw = get_tree().get_first_node_in_group("game_world")
|
||||||
|
var on_fallout = false
|
||||||
|
if position_z <= 0.0 and gw and gw.has_method("_is_position_on_fallout_tile"):
|
||||||
|
on_fallout = gw._is_position_on_fallout_tile(global_position)
|
||||||
|
if on_fallout:
|
||||||
|
if "humanoid_type" in self:
|
||||||
|
var tile_center = gw._get_closest_fallout_tile_center(global_position) if gw.has_method("_get_closest_fallout_tile_center") else (gw._get_tile_center_at(global_position) if gw.has_method("_get_tile_center_at") else global_position)
|
||||||
|
var dist_to_center = global_position.distance_to(tile_center)
|
||||||
|
if dist_to_center < FALLOUT_CENTER_THRESHOLD:
|
||||||
|
global_position = tile_center
|
||||||
|
fallout_state = true
|
||||||
|
fallout_scale_progress = 1.0
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
current_direction = Direction.DOWN
|
||||||
|
rotation = deg_to_rad(45.0)
|
||||||
|
if has_method("_set_animation"):
|
||||||
|
_set_animation("FALL")
|
||||||
|
var fall_sfx_node = get_node_or_null("SfxFallout")
|
||||||
|
if fall_sfx_node:
|
||||||
|
fall_sfx_node.play()
|
||||||
|
else:
|
||||||
|
var dir = (tile_center - global_position).normalized()
|
||||||
|
const FALLOUT_DRAG_STRENGTH: float = 820.0
|
||||||
|
velocity = dir * FALLOUT_DRAG_STRENGTH * get_process_delta_time()
|
||||||
|
if has_method("_set_animation"):
|
||||||
|
_set_animation("RUN")
|
||||||
|
move_and_slide()
|
||||||
|
return true
|
||||||
|
else:
|
||||||
|
# Slime-like / spider / rat: rotate 45 and scale down then die
|
||||||
|
fallout_defeat_started = true
|
||||||
|
fallout_scale_progress = 1.0
|
||||||
|
rotation = deg_to_rad(45.0)
|
||||||
|
var fall_sfx_slime = get_node_or_null("SfxFallout")
|
||||||
|
if fall_sfx_slime:
|
||||||
|
fall_sfx_slime.play()
|
||||||
|
move_and_slide()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
func _ai_behavior(_delta):
|
func _ai_behavior(_delta):
|
||||||
# Override in subclasses
|
# Override in subclasses
|
||||||
@@ -528,6 +561,12 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals
|
|||||||
else:
|
else:
|
||||||
# Fallback: try direct RPC (may fail if node path doesn't match)
|
# Fallback: try direct RPC (may fail if node path doesn't match)
|
||||||
_sync_damage_visual.rpc(actual_damage, from_position, is_critical)
|
_sync_damage_visual.rpc(actual_damage, from_position, is_critical)
|
||||||
|
# Sync boss health so joiner's HUD boss bar updates
|
||||||
|
if has_meta("is_boss") and get_meta("is_boss") and game_world and game_world.has_method("_rpc_to_ready_peers") and game_world.has_method("_sync_boss_health"):
|
||||||
|
var max_hp = max_health
|
||||||
|
if character_stats:
|
||||||
|
max_hp = character_stats.maxhp
|
||||||
|
game_world._rpc_to_ready_peers("_sync_boss_health", [enemy_name, enemy_index, current_health, max_hp])
|
||||||
|
|
||||||
if current_health <= 0:
|
if current_health <= 0:
|
||||||
# Prevent multiple death triggers
|
# Prevent multiple death triggers
|
||||||
@@ -582,7 +621,7 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo
|
|||||||
else:
|
else:
|
||||||
damage_label.label = str(int(amount))
|
damage_label.label = str(int(amount))
|
||||||
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red
|
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red
|
||||||
damage_label.z_index = 5
|
damage_label.z_index = 50 if (has_meta("is_boss") and get_meta("is_boss")) else 5 # Boss: draw above sprite
|
||||||
|
|
||||||
# Calculate direction from attacker (slight upward variation)
|
# Calculate direction from attacker (slight upward variation)
|
||||||
var direction_from_attacker = (global_position - from_position).normalized()
|
var direction_from_attacker = (global_position - from_position).normalized()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.12–0.22
|
# Shield: ~24% get shield, block chance 0.06–0.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
@@ -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
|
||||||
1
src/scripts/enemy_spider.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://7e0vssvq87hn
|
||||||
@@ -15,6 +15,9 @@ var screenshake_timer: float = 0.0
|
|||||||
var screenshake_duration: float = 0.0
|
var screenshake_duration: float = 0.0
|
||||||
var screenshake_strength: float = 0.0
|
var screenshake_strength: float = 0.0
|
||||||
|
|
||||||
|
# Boss intro: camera lerps to boss, then scream effect, then back to player
|
||||||
|
var camera_override_target: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
var local_players = []
|
var local_players = []
|
||||||
const BASE_CAMERA_ZOOM: float = 4.0
|
const BASE_CAMERA_ZOOM: float = 4.0
|
||||||
const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices
|
const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices
|
||||||
@@ -78,7 +81,10 @@ var _torch_darken_last_room_id: String = ""
|
|||||||
var _torch_darken_target_scale: float = 1.0
|
var _torch_darken_target_scale: float = 1.0
|
||||||
var _torch_darken_current_scale: float = 1.0
|
var _torch_darken_current_scale: float = 1.0
|
||||||
const _TORCH_DARKEN_LERP_SPEED: float = 4.0
|
const _TORCH_DARKEN_LERP_SPEED: float = 4.0
|
||||||
const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through
|
const _TORCH_DARKEN_MIN_SCALE: float = 0.52 # Floor brightness so it's never insanely dark; same for all players
|
||||||
|
var _synced_darkness_scale: float = 1.0 # Server syncs this to clients so host and joiner see same darkness
|
||||||
|
var _last_synced_darkness_sent: float = -1.0 # Server: last value we sent
|
||||||
|
var _darkness_sync_timer: float = 0.0 # Server: throttle sync RPCs
|
||||||
var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen)
|
var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen)
|
||||||
var combined_seen: PackedInt32Array = PackedInt32Array()
|
var combined_seen: PackedInt32Array = PackedInt32Array()
|
||||||
var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored
|
var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored
|
||||||
@@ -120,6 +126,10 @@ var last_safe_position_by_player: Dictionary = {} # player node path or name ->
|
|||||||
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile)
|
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile)
|
||||||
const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile before it breaks
|
const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile before it breaks
|
||||||
const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16)
|
const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16)
|
||||||
|
# Cracked floor: normally invisible; once per game per tile a player can roll perception when close to reveal
|
||||||
|
const CRACKED_DETECTION_RADIUS: float = 99.0 # Same as trap detection (pixels)
|
||||||
|
var cracked_revealed_tiles: Dictionary = {} # "x,y" -> true
|
||||||
|
var cracked_detection_attempts: Dictionary = {} # "peer_id|x|y" -> true
|
||||||
# Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
|
# Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
|
||||||
const _FALLOUT_CENTER = Vector2i(10, 12)
|
const _FALLOUT_CENTER = Vector2i(10, 12)
|
||||||
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11)
|
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11)
|
||||||
@@ -157,6 +167,9 @@ var broken_objects: Dictionary = {} # object_index -> true
|
|||||||
# Track defeated enemies (enemy_index -> true) for syncing to new clients
|
# Track defeated enemies (enemy_index -> true) for syncing to new clients
|
||||||
var defeated_enemies: Dictionary = {} # enemy_index -> true
|
var defeated_enemies: Dictionary = {} # enemy_index -> true
|
||||||
|
|
||||||
|
# Next enemy index for dynamically spawned enemies (e.g. boss spiders) - server only, avoid clash with dungeon indices
|
||||||
|
var next_dynamic_enemy_index: int = 50000
|
||||||
|
|
||||||
# Track opened chests (object_index -> true) for syncing to new clients
|
# Track opened chests (object_index -> true) for syncing to new clients
|
||||||
var opened_chests: Dictionary = {} # object_index -> true
|
var opened_chests: Dictionary = {} # object_index -> true
|
||||||
|
|
||||||
@@ -182,6 +195,9 @@ var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, pla
|
|||||||
func _ready():
|
func _ready():
|
||||||
# Add to group for easy access
|
# Add to group for easy access
|
||||||
add_to_group("game_world")
|
add_to_group("game_world")
|
||||||
|
# Apply any boss spider spawns that arrived before we were in tree (joiner fix)
|
||||||
|
if network_manager and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||||
|
network_manager.apply_pending_boss_spider_spawns(self)
|
||||||
|
|
||||||
# Connect network signals
|
# Connect network signals
|
||||||
if network_manager:
|
if network_manager:
|
||||||
@@ -200,6 +216,19 @@ func _ready():
|
|||||||
# Initialize mouse cursor system
|
# Initialize mouse cursor system
|
||||||
_init_mouse_cursor()
|
_init_mouse_cursor()
|
||||||
|
|
||||||
|
# Startup arg: -dungeonlevel=N or --dungeonlevel=N so host/single-player starts on that level
|
||||||
|
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer():
|
||||||
|
for arg in OS.get_cmdline_args():
|
||||||
|
var level_val: int = -1
|
||||||
|
if arg.begins_with("-dungeonlevel="):
|
||||||
|
level_val = int(arg.split("=")[1])
|
||||||
|
elif arg.begins_with("--dungeonlevel="):
|
||||||
|
level_val = int(arg.split("=")[1])
|
||||||
|
if level_val >= 1:
|
||||||
|
current_level = clampi(level_val, 1, 99)
|
||||||
|
LogManager.log("GameWorld: Starting on level " + str(current_level) + " (from -dungeonlevel=" + str(level_val) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
break
|
||||||
|
|
||||||
# Generate dungeon on host only
|
# Generate dungeon on host only
|
||||||
# Only generate if we're the server (not just "no multiplayer peer")
|
# Only generate if we're the server (not just "no multiplayer peer")
|
||||||
# This prevents clients from generating their own dungeon before connecting
|
# This prevents clients from generating their own dungeon before connecting
|
||||||
@@ -472,6 +501,12 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
|
|||||||
if is_inside_tree():
|
if is_inside_tree():
|
||||||
_push_existing_players_state_to_client(peer_id)
|
_push_existing_players_state_to_client(peer_id)
|
||||||
)
|
)
|
||||||
|
# Send current darkness scale so joiner sees same brightness as host immediately
|
||||||
|
get_tree().create_timer(0.25).timeout.connect(func():
|
||||||
|
if is_inside_tree() and multiplayer.is_server():
|
||||||
|
var dark_scale = maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
|
||||||
|
_sync_darkness_scale.rpc_id(peer_id, dark_scale)
|
||||||
|
)
|
||||||
|
|
||||||
# Sync broken interactable objects to the new client (immediate + delayed retry so joiner always gets it after objects exist)
|
# Sync broken interactable objects to the new client (immediate + delayed retry so joiner always gets it after objects exist)
|
||||||
call_deferred("_sync_broken_objects_to_client", peer_id)
|
call_deferred("_sync_broken_objects_to_client", peer_id)
|
||||||
@@ -483,6 +518,17 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
|
|||||||
# Sync existing enemies (from spawners) to the new client
|
# Sync existing enemies (from spawners) to the new client
|
||||||
# Wait a bit after dungeon sync to ensure spawners are spawned first
|
# Wait a bit after dungeon sync to ensure spawners are spawned first
|
||||||
call_deferred("_sync_existing_enemies_to_client", peer_id)
|
call_deferred("_sync_existing_enemies_to_client", peer_id)
|
||||||
|
# Sync existing boss-spawned spiders (so joiner sees them if they connected after spawn)
|
||||||
|
# Send immediately and again after delays so joiner 100% gets it (handles RPC loss or early processing)
|
||||||
|
call_deferred("_sync_existing_boss_spiders_to_client", peer_id)
|
||||||
|
get_tree().create_timer(1.5).timeout.connect(func():
|
||||||
|
if is_inside_tree() and multiplayer.is_server():
|
||||||
|
_sync_existing_boss_spiders_to_client(peer_id)
|
||||||
|
)
|
||||||
|
get_tree().create_timer(3.0).timeout.connect(func():
|
||||||
|
if is_inside_tree() and multiplayer.is_server():
|
||||||
|
_sync_existing_boss_spiders_to_client(peer_id)
|
||||||
|
)
|
||||||
|
|
||||||
# Sync existing chest open states to the new client
|
# Sync existing chest open states to the new client
|
||||||
# Wait a bit after dungeon sync to ensure objects are spawned first
|
# Wait a bit after dungeon sync to ensure objects are spawned first
|
||||||
@@ -818,6 +864,37 @@ func _sync_existing_enemies_to_client(client_peer_id: int):
|
|||||||
_sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type)
|
_sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type)
|
||||||
print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type)
|
print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type)
|
||||||
|
|
||||||
|
func _sync_existing_boss_spiders_to_client(client_peer_id: int) -> void:
|
||||||
|
# Sync any living boss-spawned spiders to a new joiner (so they see spiders that were spawned before they connected)
|
||||||
|
if not is_inside_tree() or not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
var boss = null
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if child.has_meta("is_boss") or child.has_method("_sync_boss_position_to_clients"):
|
||||||
|
boss = child
|
||||||
|
break
|
||||||
|
if not boss or not "spawned_spiders" in boss:
|
||||||
|
return
|
||||||
|
var positions: Array = []
|
||||||
|
var indices: Array = []
|
||||||
|
for spider in boss.spawned_spiders:
|
||||||
|
if is_instance_valid(spider) and not ("is_dead" in spider and spider.is_dead):
|
||||||
|
positions.append(spider.global_position)
|
||||||
|
indices.append(spider.get_meta("enemy_index") if spider.has_meta("enemy_index") else -1)
|
||||||
|
if positions.is_empty():
|
||||||
|
return
|
||||||
|
var num_spiders = positions.size()
|
||||||
|
# Pad to 3 slots (use -1 index and 0,0 for empty so client skips them)
|
||||||
|
while positions.size() < 3:
|
||||||
|
positions.append(Vector2.ZERO)
|
||||||
|
indices.append(-1)
|
||||||
|
# Use NetworkManager relay so joiner's autoload receives RPC even if GameWorld path differs
|
||||||
|
network_manager.spawn_boss_spiders_client.rpc_id(client_peer_id, positions[0].x, positions[0].y, positions[1].x, positions[1].y, positions[2].x, positions[2].y, indices[0], indices[1], indices[2])
|
||||||
|
LogManager.log("GameWorld: Synced " + str(num_spiders) + " boss spiders to client " + str(client_peer_id), LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
func _cleanup_disconnected_peers():
|
func _cleanup_disconnected_peers():
|
||||||
"""Periodically check and remove disconnected peers from client_gameworld_ready"""
|
"""Periodically check and remove disconnected peers from client_gameworld_ready"""
|
||||||
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
|
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
|
||||||
@@ -1034,6 +1111,88 @@ func _sync_enemy_spawn(spawner_name: String, spawn_position: Vector2, scene_inde
|
|||||||
# Call spawn method on the spawner with scene index and humanoid_type
|
# Call spawn method on the spawner with scene index and humanoid_type
|
||||||
spawner.spawn_enemy_at_position(spawn_position, scene_index, humanoid_type)
|
spawner.spawn_enemy_at_position(spawn_position, scene_index, humanoid_type)
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _sync_boss_web_shot(boss_x: float, boss_y: float, t1x: float, t1y: float, t2x: float, t2y: float, t3x: float, t3y: float):
|
||||||
|
# Clients spawn 3 web shots at boss position with the 3 targets (visual only)
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var web_shot_scene = load("res://scenes/attack_web_shot.tscn") as PackedScene
|
||||||
|
if not web_shot_scene:
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
var boss_pos = Vector2(boss_x, boss_y)
|
||||||
|
var targets = [Vector2(t1x, t1y), Vector2(t2x, t2y), Vector2(t3x, t3y)]
|
||||||
|
for i in range(3):
|
||||||
|
var shot = web_shot_scene.instantiate()
|
||||||
|
shot.global_position = boss_pos
|
||||||
|
if shot.has_method("set_target"):
|
||||||
|
shot.set_target(targets[i])
|
||||||
|
entities_node.add_child(shot)
|
||||||
|
|
||||||
|
func request_spawn_boss_spiders(positions: Array) -> Array:
|
||||||
|
# Server only: spawn 3 spiders with unique enemy_index, then RPC to clients. Returns array of spawned spider nodes.
|
||||||
|
var spawned: Array = []
|
||||||
|
if not multiplayer.is_server() or positions.size() < 3:
|
||||||
|
return spawned
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return spawned
|
||||||
|
var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
|
||||||
|
if not spider_scene:
|
||||||
|
return spawned
|
||||||
|
var idx0 = next_dynamic_enemy_index
|
||||||
|
var idx1 = next_dynamic_enemy_index + 1
|
||||||
|
var idx2 = next_dynamic_enemy_index + 2
|
||||||
|
next_dynamic_enemy_index += 3
|
||||||
|
var pos0: Vector2 = positions[0] if positions[0] is Vector2 else Vector2(positions[0].x, positions[0].y)
|
||||||
|
var pos1: Vector2 = positions[1] if positions[1] is Vector2 else Vector2(positions[1].x, positions[1].y)
|
||||||
|
var pos2: Vector2 = positions[2] if positions[2] is Vector2 else Vector2(positions[2].x, positions[2].y)
|
||||||
|
for i in range(3):
|
||||||
|
var pos = [pos0, pos1, pos2][i]
|
||||||
|
var idx = [idx0, idx1, idx2][i]
|
||||||
|
var spider = spider_scene.instantiate()
|
||||||
|
spider.name = "Enemy_%d" % idx
|
||||||
|
spider.set_meta("enemy_index", idx)
|
||||||
|
spider.set_meta("dungeon_spawned", true)
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
spider.set_multiplayer_authority(1)
|
||||||
|
spider.collision_mask = 1 | 2 | 64 # players, objects, walls
|
||||||
|
entities_node.add_child(spider)
|
||||||
|
spider.global_position = pos
|
||||||
|
spawned.append(spider)
|
||||||
|
# Send spawn via NetworkManager (autoload) so joiner always receives it regardless of GameWorld path
|
||||||
|
for peer_id in multiplayer.get_peers():
|
||||||
|
network_manager.spawn_boss_spiders_client.rpc_id(peer_id, pos0.x, pos0.y, pos1.x, pos1.y, pos2.x, pos2.y, idx0, idx1, idx2)
|
||||||
|
return spawned
|
||||||
|
|
||||||
|
# Called from NetworkManager.spawn_boss_spiders_client on clients (relay ensures joiner receives RPC)
|
||||||
|
func _do_client_spawn_boss_spiders(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void:
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
# Joiner: Entities not ready yet; queue so we apply when dungeon is ready
|
||||||
|
if not multiplayer.is_server() and network_manager:
|
||||||
|
network_manager.pending_boss_spider_spawns.append({"p1x": p1x, "p1y": p1y, "p2x": p2x, "p2y": p2y, "p3x": p3x, "p3y": p3y, "idx0": idx0, "idx1": idx1, "idx2": idx2})
|
||||||
|
return
|
||||||
|
var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
|
||||||
|
if not spider_scene:
|
||||||
|
return
|
||||||
|
var positions = [Vector2(p1x, p1y), Vector2(p2x, p2y), Vector2(p3x, p3y)]
|
||||||
|
var indices = [idx0, idx1, idx2]
|
||||||
|
for i in range(3):
|
||||||
|
if indices[i] < 0:
|
||||||
|
continue # Skip slot when syncing 1–2 spiders to new joiner
|
||||||
|
var spider = spider_scene.instantiate()
|
||||||
|
spider.name = "Enemy_%d" % indices[i]
|
||||||
|
spider.set_meta("enemy_index", indices[i])
|
||||||
|
spider.set_meta("dungeon_spawned", true)
|
||||||
|
spider.collision_mask = 1 | 2 | 64 # players, objects, walls
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
spider.set_multiplayer_authority(1)
|
||||||
|
entities_node.add_child(spider)
|
||||||
|
spider.global_position = positions[i]
|
||||||
|
|
||||||
# Loot ID counter (server only)
|
# Loot ID counter (server only)
|
||||||
var loot_id_counter: int = 0
|
var loot_id_counter: int = 0
|
||||||
|
|
||||||
@@ -1285,6 +1444,14 @@ func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display
|
|||||||
var heal_text = prefix + "+" + str(display_amount) + " HP"
|
var heal_text = prefix + "+" + str(display_amount) + " HP"
|
||||||
_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0)
|
_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0)
|
||||||
|
|
||||||
|
# Server-only: send enemy position to ALL peers (including joiner who may not be in client_gameworld_ready yet).
|
||||||
|
# Boss-spawned spiders and other enemies need position sync to reach joiners; _rpc_to_ready_peers can miss them.
|
||||||
|
func _broadcast_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int) -> void:
|
||||||
|
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
for peer_id in multiplayer.get_peers():
|
||||||
|
_sync_enemy_position.rpc_id(peer_id, enemy_name, enemy_index, pos, vel, z_pos, dir, frame, anim, frame_num, state_value)
|
||||||
|
|
||||||
@rpc("authority", "unreliable")
|
@rpc("authority", "unreliable")
|
||||||
func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int):
|
func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int):
|
||||||
# Clients receive enemy position updates from server
|
# Clients receive enemy position updates from server
|
||||||
@@ -1345,6 +1512,30 @@ func _sync_enemy_death(enemy_name: String, enemy_index: int):
|
|||||||
# This is okay, just log it
|
# This is okay, just log it
|
||||||
print("GameWorld: Could not find enemy for death sync: name=", enemy_name, " index=", enemy_index)
|
print("GameWorld: Could not find enemy for death sync: name=", enemy_name, " index=", enemy_index)
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _sync_boss_health(enemy_name: String, enemy_index: int, current_health: float, max_health: float) -> void:
|
||||||
|
# Clients update boss node's health so HUD boss bar shows correct value
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
var enemy = null
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if child.is_in_group("enemy"):
|
||||||
|
if child.name == enemy_name:
|
||||||
|
enemy = child
|
||||||
|
break
|
||||||
|
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
|
||||||
|
enemy = child
|
||||||
|
break
|
||||||
|
if enemy:
|
||||||
|
enemy.current_health = current_health
|
||||||
|
enemy.max_health = max_health
|
||||||
|
if enemy.get("character_stats") and enemy.character_stats:
|
||||||
|
enemy.character_stats.hp = current_health
|
||||||
|
enemy.character_stats.maxhp = max_health
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
@rpc("authority", "reliable")
|
||||||
func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false):
|
func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false):
|
||||||
# Clients receive enemy damage visual sync from server
|
# Clients receive enemy damage visual sync from server
|
||||||
@@ -1370,6 +1561,14 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
|
|||||||
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
|
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
|
||||||
enemy = child
|
enemy = child
|
||||||
break
|
break
|
||||||
|
# Fallback: boss may have different name on client; find by is_boss if exactly one
|
||||||
|
if enemy == null and enemy_index >= 0:
|
||||||
|
var boss_candidates = []
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if child.is_in_group("enemy") and child.has_meta("is_boss") and child.get_meta("is_boss"):
|
||||||
|
boss_candidates.append(child)
|
||||||
|
if boss_candidates.size() == 1:
|
||||||
|
enemy = boss_candidates[0]
|
||||||
|
|
||||||
if enemy and enemy.has_method("_sync_damage_visual"):
|
if enemy and enemy.has_method("_sync_damage_visual"):
|
||||||
# Call the enemy's _sync_damage_visual method directly (not via RPC)
|
# Call the enemy's _sync_damage_visual method directly (not via RPC)
|
||||||
@@ -1377,6 +1576,7 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
|
|||||||
else:
|
else:
|
||||||
# Enemy not found - might already be freed or never spawned
|
# Enemy not found - might already be freed or never spawned
|
||||||
# This is okay, just log it
|
# This is okay, just log it
|
||||||
|
if enemy == null:
|
||||||
print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)
|
print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
@rpc("authority", "reliable")
|
||||||
@@ -1408,6 +1608,30 @@ func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool):
|
|||||||
# Enemy not found - might already be freed or never spawned
|
# Enemy not found - might already be freed or never spawned
|
||||||
print("GameWorld: Could not find enemy for burn debuff sync: name=", enemy_name, " index=", enemy_index)
|
print("GameWorld: Could not find enemy for burn debuff sync: name=", enemy_name, " index=", enemy_index)
|
||||||
|
|
||||||
|
# Any peer can call; only server processes. Lets other player cut web when netted player's client doesn't have the web node.
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func request_cut_web(attack_pos_x: float, attack_pos_y: float, radius: float, attacker_peer_id: int) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
var attack_pos = Vector2(attack_pos_x, attack_pos_y)
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if not child is Area2D:
|
||||||
|
continue
|
||||||
|
if not child.has_method("cut_by_attack") or child.get("state") != "hit_player":
|
||||||
|
continue
|
||||||
|
var hit_player_node = child.get("hit_player")
|
||||||
|
if not is_instance_valid(hit_player_node) or not hit_player_node.is_in_group("player"):
|
||||||
|
continue
|
||||||
|
# Don't let netted player "cut" their own web
|
||||||
|
if hit_player_node.get_multiplayer_authority() == attacker_peer_id:
|
||||||
|
continue
|
||||||
|
if child.global_position.distance_to(attack_pos) <= radius:
|
||||||
|
child.cut_by_attack(null)
|
||||||
|
return
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
@rpc("authority", "reliable")
|
||||||
func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2):
|
func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2):
|
||||||
# Clients receive enemy attack sync from server
|
# Clients receive enemy attack sync from server
|
||||||
@@ -1843,6 +2067,20 @@ func _sync_loot_remove(loot_id: int, loot_position: Vector2):
|
|||||||
# Loot not found - might already be freed or never spawned
|
# Loot not found - might already be freed or never spawned
|
||||||
print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position)
|
print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position)
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _sync_key_respawn(loot_id: int, safe_position: Vector2):
|
||||||
|
# Clients: reposition key loot at safe tile after it sank into fallout (keys respawn like player)
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if child.is_in_group("loot") and child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id:
|
||||||
|
if child.has_method("_respawn_key_at_safe_position"):
|
||||||
|
child._respawn_key_at_safe_position(safe_position)
|
||||||
|
break
|
||||||
|
|
||||||
func _check_tab_visibility():
|
func _check_tab_visibility():
|
||||||
# Check if browser tab is visible (web only)
|
# Check if browser tab is visible (web only)
|
||||||
if OS.get_name() == "Web":
|
if OS.get_name() == "Web":
|
||||||
@@ -1872,6 +2110,10 @@ func _process(delta):
|
|||||||
if use_mouse_control:
|
if use_mouse_control:
|
||||||
_update_mouse_cursor(delta)
|
_update_mouse_cursor(delta)
|
||||||
|
|
||||||
|
# Client: apply any pending boss spider spawns (in case RPC arrived when we weren't findable)
|
||||||
|
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||||
|
network_manager.apply_pending_boss_spider_spawns(self)
|
||||||
|
|
||||||
# Check tab visibility for buffer overflow protection (clients only)
|
# Check tab visibility for buffer overflow protection (clients only)
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
var is_tab_visible = _check_tab_visibility()
|
var is_tab_visible = _check_tab_visibility()
|
||||||
@@ -1932,6 +2174,8 @@ func _process(delta):
|
|||||||
# Cracked floor: only server (or single-player) checks stand time and breaks tiles.
|
# Cracked floor: only server (or single-player) checks stand time and breaks tiles.
|
||||||
# On server, check ALL players (host + joiners) so joiners can break cracked tiles too.
|
# On server, check ALL players (host + joiners) so joiners can break cracked tiles too.
|
||||||
if dungeon_tilemap_layer_cracked and (multiplayer.is_server() or not multiplayer.has_multiplayer_peer()):
|
if dungeon_tilemap_layer_cracked and (multiplayer.is_server() or not multiplayer.has_multiplayer_peer()):
|
||||||
|
# Perception-based detection: when a player gets close to an unrevealed cracked tile, they roll once per game
|
||||||
|
_try_cracked_floor_detection()
|
||||||
var players_to_check: Array = get_tree().get_nodes_in_group("player") if (multiplayer.is_server() and multiplayer.has_multiplayer_peer()) else (player_manager.get_local_players() if player_manager else [])
|
var players_to_check: Array = get_tree().get_nodes_in_group("player") if (multiplayer.is_server() and multiplayer.has_multiplayer_peer()) else (player_manager.get_local_players() if player_manager else [])
|
||||||
if players_to_check.is_empty() and player_manager:
|
if players_to_check.is_empty() and player_manager:
|
||||||
players_to_check = player_manager.get_local_players()
|
players_to_check = player_manager.get_local_players()
|
||||||
@@ -2100,14 +2344,18 @@ func _update_camera():
|
|||||||
else:
|
else:
|
||||||
screenshake_offset = Vector2.ZERO
|
screenshake_offset = Vector2.ZERO
|
||||||
|
|
||||||
# Calculate center of all local players
|
# Calculate center of all local players (or use override for boss intro)
|
||||||
var center = Vector2.ZERO
|
var center = Vector2.ZERO
|
||||||
|
if camera_override_target != Vector2.ZERO:
|
||||||
|
center = camera_override_target
|
||||||
|
else:
|
||||||
for player in local_players:
|
for player in local_players:
|
||||||
center += player.position
|
center += player.position
|
||||||
center /= local_players.size()
|
center /= local_players.size()
|
||||||
|
|
||||||
# Smooth camera movement (with screenshake)
|
# Smooth camera movement (with screenshake); slower lerp when overriding (boss intro - gentle move to boss)
|
||||||
camera.position = camera.position.lerp(center + screenshake_offset, 0.1)
|
var lerp_weight = 0.055 if camera_override_target != Vector2.ZERO else 0.1
|
||||||
|
camera.position = camera.position.lerp(center + screenshake_offset, lerp_weight)
|
||||||
|
|
||||||
# Base zoom with aspect ratio adjustment (show more on wider screens)
|
# Base zoom with aspect ratio adjustment (show more on wider screens)
|
||||||
var viewport_size = get_viewport().get_visible_rect().size
|
var viewport_size = get_viewport().get_visible_rect().size
|
||||||
@@ -2153,6 +2401,81 @@ func add_screenshake(strength: float, duration: float):
|
|||||||
screenshake_duration = max(screenshake_duration, duration) # Use max duration
|
screenshake_duration = max(screenshake_duration, duration) # Use max duration
|
||||||
screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger
|
screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger
|
||||||
|
|
||||||
|
func start_boss_intro_sequence(boss_node: Node) -> void:
|
||||||
|
# Only run on server / single player
|
||||||
|
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
|
||||||
|
return
|
||||||
|
if not is_instance_valid(boss_node):
|
||||||
|
return
|
||||||
|
var boss_pos = boss_node.global_position
|
||||||
|
# Joiner: run same camera + scream sequence locally (RPC so they see the pan too)
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
_run_boss_intro_camera_client.rpc(boss_pos.x, boss_pos.y)
|
||||||
|
# Sequence: let player walk a couple of tiles into the room, then slowly lerp camera to boss, scream effect, return camera, start boss
|
||||||
|
var tween = create_tween()
|
||||||
|
tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
|
||||||
|
# Wait for player to be a couple of tiles into the boss room before moving camera
|
||||||
|
tween.tween_interval(1.5)
|
||||||
|
tween.tween_callback(func():
|
||||||
|
if is_instance_valid(boss_node):
|
||||||
|
camera_override_target = boss_node.global_position
|
||||||
|
else:
|
||||||
|
camera_override_target = boss_pos
|
||||||
|
)
|
||||||
|
# Slower camera lerp needs more time to reach boss (~2.8s to get most of the way there)
|
||||||
|
tween.tween_interval(2.8)
|
||||||
|
tween.tween_callback(_add_boss_scream_effect)
|
||||||
|
tween.tween_interval(1.2)
|
||||||
|
tween.tween_callback(func():
|
||||||
|
camera_override_target = Vector2.ZERO
|
||||||
|
)
|
||||||
|
# 0.5s camera back to player
|
||||||
|
tween.tween_interval(0.5)
|
||||||
|
tween.tween_callback(func():
|
||||||
|
if is_instance_valid(boss_node) and boss_node.has_method("_on_boss_intro_finished"):
|
||||||
|
boss_node._on_boss_intro_finished()
|
||||||
|
)
|
||||||
|
|
||||||
|
func _add_boss_scream_effect() -> void:
|
||||||
|
var layer = CanvasLayer.new()
|
||||||
|
layer.layer = 250
|
||||||
|
layer.name = "BossIntroEffect"
|
||||||
|
var rect = ColorRect.new()
|
||||||
|
rect.name = "ScreamRect"
|
||||||
|
rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
rect.offset_left = -1000
|
||||||
|
rect.offset_top = -1000
|
||||||
|
rect.offset_right = 2000
|
||||||
|
rect.offset_bottom = 2000
|
||||||
|
rect.color = Color(0.1, 0.0, 0.15, 0.0)
|
||||||
|
layer.add_child(rect)
|
||||||
|
add_child(layer)
|
||||||
|
var t = create_tween()
|
||||||
|
t.tween_property(rect, "color", Color(0.15, 0.0, 0.2, 0.65), 0.25).set_ease(Tween.EASE_OUT)
|
||||||
|
t.tween_interval(0.4)
|
||||||
|
t.tween_property(rect, "color", Color(0.1, 0.0, 0.15, 0.0), 0.35).set_ease(Tween.EASE_IN)
|
||||||
|
t.tween_callback(func(): layer.queue_free())
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _run_boss_intro_camera_client(boss_pos_x: float, boss_pos_y: float) -> void:
|
||||||
|
# Clients: run same camera pan + scream so joiner sees the intro (server runs full sequence and activates boss)
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var boss_pos = Vector2(boss_pos_x, boss_pos_y)
|
||||||
|
var tween = create_tween()
|
||||||
|
tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
|
||||||
|
tween.tween_interval(1.5)
|
||||||
|
tween.tween_callback(func():
|
||||||
|
camera_override_target = boss_pos
|
||||||
|
)
|
||||||
|
tween.tween_interval(2.8)
|
||||||
|
tween.tween_callback(_add_boss_scream_effect)
|
||||||
|
tween.tween_interval(1.2)
|
||||||
|
tween.tween_callback(func():
|
||||||
|
camera_override_target = Vector2.ZERO
|
||||||
|
)
|
||||||
|
tween.tween_interval(0.5)
|
||||||
|
|
||||||
func _init_mouse_cursor():
|
func _init_mouse_cursor():
|
||||||
# Create cursor layer with high Z index
|
# Create cursor layer with high Z index
|
||||||
cursor_layer = CanvasLayer.new()
|
cursor_layer = CanvasLayer.new()
|
||||||
@@ -2568,6 +2891,88 @@ func _is_position_on_cracked_tile(world_pos: Vector2) -> bool:
|
|||||||
return false
|
return false
|
||||||
return td.get_custom_data("terrain") == -2
|
return td.get_custom_data("terrain") == -2
|
||||||
|
|
||||||
|
func _try_cracked_floor_detection() -> void:
|
||||||
|
# When a player gets close to an unrevealed cracked tile, they get one perception roll per tile per game (like traps)
|
||||||
|
if dungeon_data.is_empty() or not dungeon_data.has("cracked_tile_grid") or not dungeon_tilemap_layer_cracked:
|
||||||
|
return
|
||||||
|
var cracked_tile_grid = dungeon_data.cracked_tile_grid
|
||||||
|
var map_size: Vector2i = dungeon_data.map_size
|
||||||
|
var switch_tiles: Dictionary = {}
|
||||||
|
if dungeon_data.has("blocking_doors"):
|
||||||
|
var bd = dungeon_data.blocking_doors
|
||||||
|
var bd_array = bd if bd is Array else (bd.doors if "doors" in bd else [])
|
||||||
|
for door_data in bd_array:
|
||||||
|
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
|
||||||
|
switch_tiles[str(door_data.switch_tile_x) + "," + str(door_data.switch_tile_y)] = true
|
||||||
|
var players: Array = get_tree().get_nodes_in_group("player")
|
||||||
|
if players.is_empty() and player_manager:
|
||||||
|
players = player_manager.get_local_players()
|
||||||
|
for player in players:
|
||||||
|
if not is_instance_valid(player) or not player.is_in_group("player"):
|
||||||
|
continue
|
||||||
|
var pos: Vector2 = player.global_position
|
||||||
|
if player.has_node("QuicksandArea"):
|
||||||
|
var qa = player.get_node("QuicksandArea")
|
||||||
|
if is_instance_valid(qa):
|
||||||
|
pos = qa.global_position
|
||||||
|
var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
|
||||||
|
for x in range(map_size.x):
|
||||||
|
for y in range(map_size.y):
|
||||||
|
if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size():
|
||||||
|
continue
|
||||||
|
if not cracked_tile_grid[x][y]:
|
||||||
|
continue
|
||||||
|
if switch_tiles.has(str(x) + "," + str(y)):
|
||||||
|
continue
|
||||||
|
var key_revealed = str(x) + "," + str(y)
|
||||||
|
if cracked_revealed_tiles.has(key_revealed):
|
||||||
|
continue
|
||||||
|
var tile_center: Vector2 = dungeon_tilemap_layer_cracked.map_to_local(Vector2i(x, y)) + dungeon_tilemap_layer_cracked.global_position
|
||||||
|
if pos.distance_to(tile_center) > CRACKED_DETECTION_RADIUS:
|
||||||
|
continue
|
||||||
|
var key_attempt = str(peer_id) + "|" + str(x) + "|" + str(y)
|
||||||
|
if cracked_detection_attempts.has(key_attempt):
|
||||||
|
continue
|
||||||
|
cracked_detection_attempts[key_attempt] = true
|
||||||
|
_roll_cracked_perception_check(player, x, y)
|
||||||
|
return # One roll per frame max
|
||||||
|
|
||||||
|
func _roll_cracked_perception_check(player: Node, tile_x: int, tile_y: int) -> void:
|
||||||
|
if not player or not player.character_stats:
|
||||||
|
return
|
||||||
|
var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per")
|
||||||
|
var roll = randi() % 20 + 1
|
||||||
|
var total = roll + int(per_stat / 2) - 5
|
||||||
|
var dc = 15
|
||||||
|
if total >= dc:
|
||||||
|
_reveal_cracked_tile(tile_x, tile_y, player)
|
||||||
|
else:
|
||||||
|
pass # Tile stays hidden for this attempt
|
||||||
|
|
||||||
|
func _reveal_cracked_tile(tile_x: int, tile_y: int, detecting_player: Node) -> void:
|
||||||
|
var key = str(tile_x) + "," + str(tile_y)
|
||||||
|
cracked_revealed_tiles[key] = true
|
||||||
|
if dungeon_tilemap_layer_cracked:
|
||||||
|
dungeon_tilemap_layer_cracked.set_cell(Vector2i(tile_x, tile_y), 0, CRACKED_TILE_ATLAS)
|
||||||
|
var tile_center = dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position
|
||||||
|
spawn_detected_effect_at(tile_center, "", "trap")
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
_sync_cracked_tile_revealed.rpc(tile_x, tile_y)
|
||||||
|
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_cracked_floor_detected"):
|
||||||
|
if detecting_player.is_multiplayer_authority():
|
||||||
|
detecting_player._on_cracked_floor_detected()
|
||||||
|
else:
|
||||||
|
detecting_player._on_cracked_floor_detected.rpc_id(detecting_player.get_multiplayer_authority())
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable", "call_remote")
|
||||||
|
func _sync_cracked_tile_revealed(tile_x: int, tile_y: int) -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
if not cracked_revealed_tiles.has(str(tile_x) + "," + str(tile_y)):
|
||||||
|
cracked_revealed_tiles[str(tile_x) + "," + str(tile_y)] = true
|
||||||
|
if dungeon_tilemap_layer_cracked:
|
||||||
|
dungeon_tilemap_layer_cracked.set_cell(Vector2i(tile_x, tile_y), 0, CRACKED_TILE_ATLAS)
|
||||||
|
|
||||||
func _get_tile_coords_at_world(world_pos: Vector2) -> Vector2i:
|
func _get_tile_coords_at_world(world_pos: Vector2) -> Vector2i:
|
||||||
if not dungeon_tilemap_layer:
|
if not dungeon_tilemap_layer:
|
||||||
return Vector2i(-1, -1)
|
return Vector2i(-1, -1)
|
||||||
@@ -2626,6 +3031,68 @@ func _play_whoosh_at(world_pos: Vector2) -> void:
|
|||||||
player.play()
|
player.play()
|
||||||
player.finished.connect(player.queue_free)
|
player.finished.connect(player.queue_free)
|
||||||
|
|
||||||
|
func spawn_detected_effect_at(world_pos: Vector2, parent_node_name: String = "", effect_type: String = "chest") -> void:
|
||||||
|
# Spawn the "detected" effect at position; sync to all players.
|
||||||
|
# effect_type: "chest" (blue, 169-179), "trap" (purple, 274-284), "enemy" (red, 484-494).
|
||||||
|
# If parent_node_name is set (e.g. trap/enemy hand name), effect is added as child of that node so it can be removed when disarmed/emerged.
|
||||||
|
# For "enemy" type we always add to Entities so the effect stays visible (animating) while the hidden enemy has modulate.a = 0.
|
||||||
|
var scene = load("res://scenes/detected_effect.tscn") as PackedScene
|
||||||
|
if not scene:
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
entities_node = self
|
||||||
|
var parent = null
|
||||||
|
if parent_node_name and effect_type != "enemy":
|
||||||
|
parent = entities_node.get_node_or_null(parent_node_name)
|
||||||
|
var effect = scene.instantiate()
|
||||||
|
if parent:
|
||||||
|
parent.add_child(effect)
|
||||||
|
else:
|
||||||
|
entities_node.add_child(effect)
|
||||||
|
effect.global_position = world_pos
|
||||||
|
if effect.has_method("setup"):
|
||||||
|
effect.setup(world_pos, effect_type)
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
_sync_spawn_detected_effect.rpc(world_pos.x, world_pos.y, parent_node_name, effect_type)
|
||||||
|
|
||||||
|
func remove_detected_effect_at_position(world_pos: Vector2) -> void:
|
||||||
|
# Remove a detected effect at/near this position (e.g. when cracked tile breaks and someone falls through)
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
const TOLERANCE = 20.0 # One tile + margin so we match the effect we spawned here
|
||||||
|
for c in entities_node.get_children():
|
||||||
|
if (c.name == "DetectedEffect" or c.name.begins_with("DetectedEffect")) and c.global_position.distance_to(world_pos) < TOLERANCE:
|
||||||
|
c.queue_free()
|
||||||
|
return
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable", "call_remote")
|
||||||
|
func _sync_remove_detected_effect_at_position(px: float, py: float) -> void:
|
||||||
|
remove_detected_effect_at_position(Vector2(px, py))
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable", "call_remote")
|
||||||
|
func _sync_spawn_detected_effect(px: float, py: float, parent_node_name: String = "", effect_type: String = "chest") -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var scene = load("res://scenes/detected_effect.tscn") as PackedScene
|
||||||
|
if not scene:
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
entities_node = self
|
||||||
|
var parent = null
|
||||||
|
if parent_node_name and effect_type != "enemy":
|
||||||
|
parent = entities_node.get_node_or_null(parent_node_name)
|
||||||
|
var effect = scene.instantiate()
|
||||||
|
if parent:
|
||||||
|
parent.add_child(effect)
|
||||||
|
else:
|
||||||
|
entities_node.add_child(effect)
|
||||||
|
effect.global_position = Vector2(px, py)
|
||||||
|
if effect.has_method("setup"):
|
||||||
|
effect.setup(Vector2(px, py), effect_type)
|
||||||
|
|
||||||
func break_cracked_tiles_in_radius(world_center: Vector2, radius: float) -> void:
|
func break_cracked_tiles_in_radius(world_center: Vector2, radius: float) -> void:
|
||||||
# Break any cracked tiles inside the given world-space circle. Only server performs the break.
|
# Break any cracked tiles inside the given world-space circle. Only server performs the break.
|
||||||
# Clients (e.g. joiner's bomb) request the server to do it via RPC.
|
# Clients (e.g. joiner's bomb) request the server to do it via RPC.
|
||||||
@@ -2663,10 +3130,12 @@ func _break_cracked_tile(tile_x: int, tile_y: int) -> void:
|
|||||||
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
|
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
|
||||||
if dungeon_tilemap_layer_decorated:
|
if dungeon_tilemap_layer_decorated:
|
||||||
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
||||||
|
# Tile center for whoosh/effect removal: use same layer as spawn (cracked) so position matches
|
||||||
|
var tile_center = (dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position) if dungeon_tilemap_layer_cracked else (dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position)
|
||||||
if dungeon_tilemap_layer_cracked:
|
if dungeon_tilemap_layer_cracked:
|
||||||
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
|
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
|
||||||
var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position
|
|
||||||
_play_whoosh_at(tile_center)
|
_play_whoosh_at(tile_center)
|
||||||
|
remove_detected_effect_at_position(tile_center)
|
||||||
# Update dungeon_data so re-packed blob for joiners has correct floor (no separate broken list needed)
|
# Update dungeon_data so re-packed blob for joiners has correct floor (no separate broken list needed)
|
||||||
if multiplayer.is_server() and not dungeon_data.is_empty():
|
if multiplayer.is_server() and not dungeon_data.is_empty():
|
||||||
if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0:
|
if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0:
|
||||||
@@ -2695,8 +3164,10 @@ func _sync_cracked_tile_broke(tile_x: int, tile_y: int, fallout_atlas_x: int, fa
|
|||||||
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
||||||
if dungeon_tilemap_layer_cracked:
|
if dungeon_tilemap_layer_cracked:
|
||||||
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
|
dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y))
|
||||||
var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position if dungeon_tilemap_layer else Vector2(tile_x * 16 + 8, tile_y * 16 + 8)
|
# Match spawn position: use cracked layer if available, else main layer, else tile coords
|
||||||
|
var tile_center = (dungeon_tilemap_layer_cracked.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer_cracked.global_position) if dungeon_tilemap_layer_cracked else ((dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position) if dungeon_tilemap_layer else Vector2(tile_x * 16 + 8, tile_y * 16 + 8))
|
||||||
_play_whoosh_at(tile_center)
|
_play_whoosh_at(tile_center)
|
||||||
|
remove_detected_effect_at_position(tile_center)
|
||||||
|
|
||||||
func update_last_safe_position_for_player(player: Node, world_pos: Vector2) -> void:
|
func update_last_safe_position_for_player(player: Node, world_pos: Vector2) -> void:
|
||||||
# Only store when position is on a non-fallout tile so we never remember a pit as safe
|
# Only store when position is on a non-fallout tile so we never remember a pit as safe
|
||||||
@@ -2813,6 +3284,9 @@ func _init_fog_of_war():
|
|||||||
_torch_darken_last_room_id = ""
|
_torch_darken_last_room_id = ""
|
||||||
_torch_darken_target_scale = 1.0
|
_torch_darken_target_scale = 1.0
|
||||||
_torch_darken_current_scale = 1.0
|
_torch_darken_current_scale = 1.0
|
||||||
|
_synced_darkness_scale = 1.0
|
||||||
|
_last_synced_darkness_sent = -1.0
|
||||||
|
_darkness_sync_timer = 0.0
|
||||||
|
|
||||||
func _update_fog_of_war(delta: float) -> void:
|
func _update_fog_of_war(delta: float) -> void:
|
||||||
if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
|
if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
|
||||||
@@ -2986,7 +3460,14 @@ func _update_fog_of_war(delta: float) -> void:
|
|||||||
break
|
break
|
||||||
if exit_discovered:
|
if exit_discovered:
|
||||||
break
|
break
|
||||||
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered)
|
var other_player_tiles: Array = []
|
||||||
|
var all_players = get_tree().get_nodes_in_group("player")
|
||||||
|
for p in all_players:
|
||||||
|
if not is_instance_valid(p) or p in local_player_list:
|
||||||
|
continue
|
||||||
|
var pt = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
|
||||||
|
other_player_tiles.append(pt)
|
||||||
|
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles)
|
||||||
|
|
||||||
func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
|
func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
|
||||||
var size = map_size.x * map_size.y
|
var size = map_size.x * map_size.y
|
||||||
@@ -3149,13 +3630,57 @@ func _median_torch_scale_from_rooms(rooms: Array) -> float:
|
|||||||
median = clampf(median, 0.0, 4.0)
|
median = clampf(median, 0.0, 4.0)
|
||||||
return median / 4.0
|
return median / 4.0
|
||||||
|
|
||||||
|
# Map raw torch scale (0..1) to display scale so we never go insanely dark (0.52..1.0)
|
||||||
|
func _torch_scale_to_display(raw: float) -> float:
|
||||||
|
return clampf(_TORCH_DARKEN_MIN_SCALE + (1.0 - _TORCH_DARKEN_MIN_SCALE) * raw, _TORCH_DARKEN_MIN_SCALE, 1.0)
|
||||||
|
|
||||||
|
# Compute target darkness scale for a given world position (for sync: server can use any player's position)
|
||||||
|
func _get_darkness_scale_at_position(world_pos: Vector2) -> float:
|
||||||
|
var p_tile = Vector2i(int(world_pos.x / FOG_TILE_SIZE), int(world_pos.y / FOG_TILE_SIZE))
|
||||||
|
var current_room = _find_room_at_tile(p_tile)
|
||||||
|
var in_room := not current_room.is_empty()
|
||||||
|
var raw: float
|
||||||
|
if in_room:
|
||||||
|
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
|
||||||
|
raw = tc / 4.0
|
||||||
|
else:
|
||||||
|
raw = _median_torch_scale_from_rooms(cached_corridor_rooms)
|
||||||
|
return _torch_scale_to_display(raw)
|
||||||
|
|
||||||
func _update_canvas_modulate_by_torches() -> void:
|
func _update_canvas_modulate_by_torches() -> void:
|
||||||
if dungeon_data.is_empty() or not dungeon_data.has("torches"):
|
if dungeon_data.is_empty() or not dungeon_data.has("torches"):
|
||||||
return
|
return
|
||||||
var cm = get_node_or_null("CanvasModulate")
|
var cm = get_node_or_null("CanvasModulate")
|
||||||
if not cm or not is_instance_valid(cm):
|
if not cm or not is_instance_valid(cm):
|
||||||
return
|
return
|
||||||
|
# Multiplayer: clients use server-synced scale so host and joiner see the same darkness
|
||||||
|
if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
|
||||||
|
_synced_darkness_scale = clampf(_synced_darkness_scale, _TORCH_DARKEN_MIN_SCALE, 1.0)
|
||||||
|
var dt := get_process_delta_time()
|
||||||
|
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, _synced_darkness_scale, clampf(dt * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
|
||||||
|
var brightness := _torch_darken_current_scale
|
||||||
|
cm.color = Color(brightness, brightness, brightness)
|
||||||
|
return
|
||||||
|
# Server or single player: compute target scale
|
||||||
var local_list = player_manager.get_local_players() if player_manager else []
|
var local_list = player_manager.get_local_players() if player_manager else []
|
||||||
|
var target_scale: float
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||||
|
# Server: use brightest room any player is in so everyone gets same, reasonable darkness
|
||||||
|
var all_players = get_tree().get_nodes_in_group("player")
|
||||||
|
var max_scale: float = _TORCH_DARKEN_MIN_SCALE
|
||||||
|
for pl in all_players:
|
||||||
|
if is_instance_valid(pl) and "global_position" in pl:
|
||||||
|
var sc = _get_darkness_scale_at_position(pl.global_position)
|
||||||
|
if sc > max_scale:
|
||||||
|
max_scale = sc
|
||||||
|
target_scale = max_scale
|
||||||
|
# Throttled sync to clients so they match
|
||||||
|
_darkness_sync_timer += get_process_delta_time()
|
||||||
|
if _darkness_sync_timer >= 0.15 or abs(target_scale - _last_synced_darkness_sent) >= 0.03:
|
||||||
|
_darkness_sync_timer = 0.0
|
||||||
|
_last_synced_darkness_sent = target_scale
|
||||||
|
_sync_darkness_scale.rpc(target_scale)
|
||||||
|
else:
|
||||||
if local_list.is_empty() or not local_list[0]:
|
if local_list.is_empty() or not local_list[0]:
|
||||||
return
|
return
|
||||||
var p = local_list[0]
|
var p = local_list[0]
|
||||||
@@ -3177,14 +3702,22 @@ func _update_canvas_modulate_by_torches() -> void:
|
|||||||
_torch_darken_last_room_id = room_id
|
_torch_darken_last_room_id = room_id
|
||||||
if in_room:
|
if in_room:
|
||||||
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
|
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
|
||||||
_torch_darken_target_scale = tc / 4.0
|
_torch_darken_target_scale = _torch_scale_to_display(tc / 4.0)
|
||||||
else:
|
else:
|
||||||
_torch_darken_target_scale = _median_torch_scale_from_rooms(cached_corridor_rooms)
|
_torch_darken_target_scale = _torch_scale_to_display(_median_torch_scale_from_rooms(cached_corridor_rooms))
|
||||||
|
target_scale = _torch_darken_target_scale
|
||||||
var delta := get_process_delta_time()
|
var delta := get_process_delta_time()
|
||||||
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, _torch_darken_target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
|
if not (multiplayer.has_multiplayer_peer() and multiplayer.is_server()):
|
||||||
|
_torch_darken_current_scale = lerpf(_torch_darken_current_scale, target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
|
||||||
|
else:
|
||||||
|
_torch_darken_current_scale = target_scale
|
||||||
var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
|
var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
|
||||||
cm.color = Color(s, s, s)
|
cm.color = Color(s, s, s)
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _sync_darkness_scale(darkness_scale: float) -> void:
|
||||||
|
_synced_darkness_scale = clampf(darkness_scale, _TORCH_DARKEN_MIN_SCALE, 1.0)
|
||||||
|
|
||||||
func _reapply_torch_darkening() -> void:
|
func _reapply_torch_darkening() -> void:
|
||||||
if dungeon_data.is_empty() or not dungeon_data.has("torches"):
|
if dungeon_data.is_empty() or not dungeon_data.has("torches"):
|
||||||
return
|
return
|
||||||
@@ -3198,15 +3731,15 @@ func _reapply_torch_darkening() -> void:
|
|||||||
var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
|
var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
|
||||||
var current_room = _find_room_at_tile(p_tile)
|
var current_room = _find_room_at_tile(p_tile)
|
||||||
var in_room := not current_room.is_empty()
|
var in_room := not current_room.is_empty()
|
||||||
var target: float
|
var raw: float
|
||||||
if in_room:
|
if in_room:
|
||||||
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
|
var tc = clampi(_count_torches_in_room(current_room), 0, 4)
|
||||||
target = tc / 4.0
|
raw = tc / 4.0
|
||||||
else:
|
else:
|
||||||
target = _median_torch_scale_from_rooms(cached_corridor_rooms)
|
raw = _median_torch_scale_from_rooms(cached_corridor_rooms)
|
||||||
var t := maxf(target, _TORCH_DARKEN_MIN_SCALE)
|
var t := _torch_scale_to_display(raw)
|
||||||
_torch_darken_target_scale = target
|
_torch_darken_target_scale = t
|
||||||
_torch_darken_current_scale = target
|
_torch_darken_current_scale = t
|
||||||
var room_id := ""
|
var room_id := ""
|
||||||
if in_room:
|
if in_room:
|
||||||
room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
|
room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
|
||||||
@@ -3407,9 +3940,12 @@ func _generate_dungeon():
|
|||||||
if dungeon_seed == 0:
|
if dungeon_seed == 0:
|
||||||
dungeon_seed = randi()
|
dungeon_seed = randi()
|
||||||
|
|
||||||
# Create dungeon generator
|
# Create dungeon generator — dungeon can be any size; we store the size once it's ready (in dungeon_data.map_size)
|
||||||
|
# Level 4 uses a larger map so the boss arena (first room) has plenty of space for the spider bat
|
||||||
var generator = load("res://scripts/dungeon_generator.gd").new()
|
var generator = load("res://scripts/dungeon_generator.gd").new()
|
||||||
var map_size = Vector2i(72, 72) # 72x72 tiles
|
var map_size = Vector2i(72, 72) # default 72x72 tiles
|
||||||
|
if current_level == 4:
|
||||||
|
map_size = Vector2i(96, 96) # bigger map for boss level so boss room is never cramped
|
||||||
|
|
||||||
# Hide all players and remove collision before generating new level
|
# Hide all players and remove collision before generating new level
|
||||||
_hide_all_players()
|
_hide_all_players()
|
||||||
@@ -3573,15 +4109,15 @@ func _get_dungeon_color_scheme(scheme_index: int) -> Array:
|
|||||||
Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
|
Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
|
||||||
Color(0.22, 0.34, 0.30), Color(0.20, 0.30, 0.26),
|
Color(0.22, 0.34, 0.30), Color(0.20, 0.30, 0.26),
|
||||||
]
|
]
|
||||||
2: # 3️⃣ Toxic Green (poison / nature / alchemy)
|
2: # 3️⃣ Toxic Green (poison / nature / alchemy) — brightened so walls and purple floor are clearly visible
|
||||||
walls = [
|
walls = [
|
||||||
Color(20 / 255.0, 120 / 255.0, 40 / 255.0), Color(60 / 255.0, 180 / 255.0, 90 / 255.0), Color(120 / 255.0, 220 / 255.0, 160 / 255.0),
|
Color(55 / 255.0, 160 / 255.0, 75 / 255.0), Color(95 / 255.0, 205 / 255.0, 125 / 255.0), Color(155 / 255.0, 238 / 255.0, 185 / 255.0),
|
||||||
Color(10 / 255.0, 60 / 255.0, 25 / 255.0), Color(180 / 255.0, 255 / 255.0, 210 / 255.0), Color(40 / 255.0, 90 / 255.0, 55 / 255.0),
|
Color(35 / 255.0, 100 / 255.0, 50 / 255.0), Color(200 / 255.0, 255 / 255.0, 225 / 255.0), Color(75 / 255.0, 130 / 255.0, 85 / 255.0),
|
||||||
]
|
]
|
||||||
ground_fallout = [
|
ground_fallout = [
|
||||||
Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56),
|
Color(0.78, 0.52, 0.82), Color(0.82, 0.58, 0.86), Color(0.62, 0.42, 0.70),
|
||||||
Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38),
|
Color(0.72, 0.48, 0.78), Color(0.68, 0.46, 0.74), Color(0.50, 0.42, 0.54),
|
||||||
Color(0.28, 0.22, 0.32), Color(0.24, 0.18, 0.28),
|
Color(0.44, 0.38, 0.48), Color(0.40, 0.34, 0.44),
|
||||||
]
|
]
|
||||||
3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility
|
3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility
|
||||||
walls = [
|
walls = [
|
||||||
@@ -3880,25 +4416,8 @@ func _render_dungeon():
|
|||||||
var dt = decorated_tile_grid[x][y]
|
var dt = decorated_tile_grid[x][y]
|
||||||
if dt != null and dt is Vector2i:
|
if dt != null and dt is Vector2i:
|
||||||
dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt)
|
dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt)
|
||||||
if dungeon_data.has("cracked_tile_grid") and dungeon_tilemap_layer_cracked:
|
# Cracked tiles: do NOT draw here; they start invisible and are revealed when a player detects them (perception roll)
|
||||||
var cracked_tile_grid = dungeon_data.cracked_tile_grid
|
# cracked_revealed_tiles and set_cell are used in _try_cracked_floor_detection()
|
||||||
# Floor switch positions must NEVER show cracked ground
|
|
||||||
var switch_tiles = {}
|
|
||||||
if dungeon_data.has("blocking_doors"):
|
|
||||||
var bd = dungeon_data.blocking_doors
|
|
||||||
var bd_array = bd if bd is Array else (bd.doors if "doors" in bd else [])
|
|
||||||
for door_data in bd_array:
|
|
||||||
if "switch_tile_x" in door_data and "switch_tile_y" in door_data:
|
|
||||||
var k = str(door_data.switch_tile_x) + "," + str(door_data.switch_tile_y)
|
|
||||||
switch_tiles[k] = true
|
|
||||||
for x in range(map_size.x):
|
|
||||||
for y in range(map_size.y):
|
|
||||||
if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size():
|
|
||||||
continue
|
|
||||||
if switch_tiles.has(str(x) + "," + str(y)):
|
|
||||||
continue
|
|
||||||
if cracked_tile_grid[x][y]:
|
|
||||||
dungeon_tilemap_layer_cracked.set_cell(Vector2i(x, y), 0, CRACKED_TILE_ATLAS)
|
|
||||||
|
|
||||||
LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_DUNGEON)
|
||||||
LogManager.log("GameWorld: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON)
|
||||||
@@ -5284,6 +5803,11 @@ func _reassemble_dungeon_blob():
|
|||||||
_spawn_room_triggers()
|
_spawn_room_triggers()
|
||||||
print("GameWorld: Client - Room triggers spawned")
|
print("GameWorld: Client - Room triggers spawned")
|
||||||
|
|
||||||
|
# CRITICAL for joiner: apply any boss spider spawns that arrived before dungeon was ready
|
||||||
|
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||||
|
network_manager.apply_pending_boss_spider_spawns(self)
|
||||||
|
LogManager.log("GameWorld: Client applied pending boss spider spawns after dungeon blob", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# Apply door states (from metadata) - after doors are spawned
|
# Apply door states (from metadata) - after doors are spawned
|
||||||
if pending_door_states.size() > 0:
|
if pending_door_states.size() > 0:
|
||||||
print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...")
|
print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...")
|
||||||
@@ -5517,6 +6041,10 @@ func _sync_dungeon_entities(non_essential_data: Dictionary):
|
|||||||
_spawn_blocking_doors()
|
_spawn_blocking_doors()
|
||||||
_spawn_room_triggers()
|
_spawn_room_triggers()
|
||||||
|
|
||||||
|
# Joiner: apply any boss spider spawns that arrived before we had Entities ready
|
||||||
|
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||||
|
network_manager.apply_pending_boss_spider_spawns(self)
|
||||||
|
|
||||||
func _fix_player_appearance_after_dungeon_sync():
|
func _fix_player_appearance_after_dungeon_sync():
|
||||||
# Re-randomize appearance for all players that were spawned before dungeon_seed was received
|
# Re-randomize appearance for all players that were spawned before dungeon_seed was received
|
||||||
# This ensures all players (including joiners) have the same appearance across all clients
|
# This ensures all players (including joiners) have the same appearance across all clients
|
||||||
@@ -5705,6 +6233,9 @@ func _spawn_enemies():
|
|||||||
# This overrides any collision_mask set in the scene file
|
# This overrides any collision_mask set in the scene file
|
||||||
enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
|
enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
|
||||||
|
|
||||||
|
# Boss flag (for HUD and behavior)
|
||||||
|
if enemy_data.get("is_boss", false):
|
||||||
|
enemy.set_meta("is_boss", true)
|
||||||
# Set position BEFORE add_child so humanoid _ready() sees correct global_position for unique appearance seed
|
# Set position BEFORE add_child so humanoid _ready() sees correct global_position for unique appearance seed
|
||||||
enemy.position = enemy_data.position
|
enemy.position = enemy_data.position
|
||||||
# Add to scene tree AFTER setting authority, stats, and position
|
# Add to scene tree AFTER setting authority, stats, and position
|
||||||
@@ -5933,6 +6464,12 @@ func _spawn_interactable_objects():
|
|||||||
else:
|
else:
|
||||||
push_error("ERROR: Object does not have method: ", object_data.setup_function)
|
push_error("ERROR: Object does not have method: ", object_data.setup_function)
|
||||||
|
|
||||||
|
# Hidden chest: make invisible until detected via perception
|
||||||
|
if object_data.get("hidden", false) and obj.object_type == "Chest":
|
||||||
|
obj.is_hidden = true
|
||||||
|
obj._apply_hidden_state()
|
||||||
|
LogManager.log("GameWorld: Spawned HIDDEN chest at " + str(object_data.position) + " (name: " + obj.name + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# Add to group for easy access
|
# Add to group for easy access
|
||||||
obj.add_to_group("interactable_object")
|
obj.add_to_group("interactable_object")
|
||||||
|
|
||||||
@@ -6302,6 +6839,17 @@ func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed:
|
|||||||
_apply_trap_state_by_name(trap_name, is_detected, is_disarmed)
|
_apply_trap_state_by_name(trap_name, is_detected, is_disarmed)
|
||||||
print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed)
|
print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed)
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable", "call_remote")
|
||||||
|
func _sync_hidden_chest_detected_by_name(chest_name: String) -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
var chest = entities_node.get_node_or_null(chest_name)
|
||||||
|
if chest and is_instance_valid(chest) and chest.has_method("_sync_hidden_chest_detected"):
|
||||||
|
chest._sync_hidden_chest_detected()
|
||||||
|
|
||||||
func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0):
|
func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0):
|
||||||
# Sync broken interactable objects to new client with retry logic
|
# Sync broken interactable objects to new client with retry logic
|
||||||
# Check if node is still valid and in tree
|
# Check if node is still valid and in tree
|
||||||
@@ -8357,6 +8905,26 @@ func _sync_exp_text_at_player(amount: float, player_peer_id: int):
|
|||||||
if player and is_instance_valid(player):
|
if player and is_instance_valid(player):
|
||||||
_show_exp_number_at_player(amount, player)
|
_show_exp_number_at_player(amount, player)
|
||||||
|
|
||||||
|
# Show damage number at world position (e.g. when attacker hits boss so they always see the number)
|
||||||
|
func show_damage_number_at_position(world_pos: Vector2, amount: float, is_critical: bool = false) -> void:
|
||||||
|
var damage_number_scene = preload("res://scenes/damage_number.tscn")
|
||||||
|
if not damage_number_scene:
|
||||||
|
return
|
||||||
|
var damage_label = damage_number_scene.instantiate()
|
||||||
|
if not damage_label:
|
||||||
|
return
|
||||||
|
damage_label.label = str(int(amount))
|
||||||
|
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35)
|
||||||
|
damage_label.z_index = 50
|
||||||
|
damage_label.direction = Vector2(0, -1)
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if entities_node:
|
||||||
|
entities_node.add_child(damage_label)
|
||||||
|
damage_label.global_position = world_pos + Vector2(0, -16)
|
||||||
|
else:
|
||||||
|
get_tree().current_scene.add_child(damage_label)
|
||||||
|
damage_label.global_position = world_pos + Vector2(0, -16)
|
||||||
|
|
||||||
func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
|
func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
|
||||||
# Show EXP number (green, using dmg_numbers.png font) at a specific position
|
# Show EXP number (green, using dmg_numbers.png font) at a specific position
|
||||||
var damage_number_scene = preload("res://scenes/damage_number.tscn")
|
var damage_number_scene = preload("res://scenes/damage_number.tscn")
|
||||||
@@ -9297,10 +9865,17 @@ func _spawn_blocking_doors():
|
|||||||
door.requires_enemies = true
|
door.requires_enemies = true
|
||||||
door.requires_switch = false
|
door.requires_switch = false
|
||||||
LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
elif door_data.puzzle_type == "boss":
|
||||||
|
door.requires_enemies = true
|
||||||
|
door.requires_switch = false
|
||||||
|
door.set_meta("boss_room_door", true) # Check all enemies in room (pre-placed boss), not just spawner
|
||||||
|
LogManager.log("GameWorld: Door " + str(door.name) + " is boss room door - requires boss defeated", LogManager.CATEGORY_DUNGEON)
|
||||||
elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]:
|
elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]:
|
||||||
door.requires_enemies = false
|
door.requires_enemies = false
|
||||||
door.requires_switch = true
|
door.requires_switch = true
|
||||||
LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON)
|
||||||
|
if door_data.get("boss_room_door", false):
|
||||||
|
door.set_meta("boss_room_door", true)
|
||||||
door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {}
|
door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {}
|
||||||
door.switch_room = door_data.switch_room if "switch_room" in door_data else {}
|
door.switch_room = door_data.switch_room if "switch_room" in door_data else {}
|
||||||
|
|
||||||
@@ -9626,7 +10201,12 @@ func _spawn_blocking_doors():
|
|||||||
if "puzzle_type" in door_data:
|
if "puzzle_type" in door_data:
|
||||||
LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
# CRITICAL: If door has no puzzle elements (neither switch nor spawner), this is an error
|
# Boss room door: puzzle is the pre-placed boss (no switch or spawner)
|
||||||
|
if "puzzle_type" in door_data and door_data.puzzle_type == "boss":
|
||||||
|
has_puzzle_element = true
|
||||||
|
LogManager.log("GameWorld: Door " + str(door.name) + " is boss room door - puzzle element is pre-placed boss", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
|
# CRITICAL: If door has no puzzle elements (neither switch nor spawner nor boss), this is an error
|
||||||
# This should never happen if dungeon_generator logic is correct, but add safety check
|
# This should never happen if dungeon_generator logic is correct, but add safety check
|
||||||
if door_data.type != "KeyDoor" and not has_puzzle_element:
|
if door_data.type != "KeyDoor" and not has_puzzle_element:
|
||||||
push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!")
|
push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!")
|
||||||
@@ -9836,6 +10416,18 @@ func _spawn_room_triggers():
|
|||||||
|
|
||||||
LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON)
|
LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON)
|
||||||
|
|
||||||
|
# Explicitly connect every blocking door to its room trigger (ensures boss room and all puzzle room doors close on enter)
|
||||||
|
_connect_all_doors_to_room_triggers()
|
||||||
|
|
||||||
|
func _connect_all_doors_to_room_triggers():
|
||||||
|
var entities_node = get_node_or_null("Entities")
|
||||||
|
if not entities_node:
|
||||||
|
return
|
||||||
|
for child in entities_node.get_children():
|
||||||
|
if (child.is_in_group("blocking_door") or (child.name and child.name.begins_with("BlockingDoor_"))) and is_instance_valid(child):
|
||||||
|
if child.get("blocking_room") and not child.blocking_room.is_empty():
|
||||||
|
_connect_door_to_room_trigger(child)
|
||||||
|
|
||||||
func _place_key_in_room(room: Dictionary):
|
func _place_key_in_room(room: Dictionary):
|
||||||
# Place a key in the specified room (as loot)
|
# Place a key in the specified room (as loot)
|
||||||
# Only run on server - keys are not synced via RPC, so clients should not spawn them
|
# Only run on server - keys are not synced via RPC, so clients should not spawn them
|
||||||
@@ -9857,14 +10449,16 @@ func _place_key_in_room(room: Dictionary):
|
|||||||
var tile_size = 16
|
var tile_size = 16
|
||||||
var valid_positions = []
|
var valid_positions = []
|
||||||
|
|
||||||
# Room interior is from room.x + 2 to room.x + room.w - 2
|
# Room interior is from room.x + 2 to room.x + room.w - 2; exclude fallout tiles so the key doesn't sink
|
||||||
for x in range(room.x + 2, room.x + room.w - 2):
|
for x in range(room.x + 2, room.x + room.w - 2):
|
||||||
for y in range(room.y + 2, room.y + room.h - 2):
|
for y in range(room.y + 2, room.y + room.h - 2):
|
||||||
if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y:
|
if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y:
|
||||||
if dungeon_data.grid[x][y] == 1: # Floor
|
if dungeon_data.grid[x][y] == 1: # Floor
|
||||||
var world_x = x * tile_size + tile_size / 2.0
|
var world_x = x * tile_size + tile_size / 2.0
|
||||||
var world_y = y * tile_size + tile_size / 2.0
|
var world_y = y * tile_size + tile_size / 2.0
|
||||||
valid_positions.append(Vector2(world_x, world_y))
|
var world_pos = Vector2(world_x, world_y)
|
||||||
|
if not _is_position_on_fallout_tile(world_pos):
|
||||||
|
valid_positions.append(world_pos)
|
||||||
|
|
||||||
if valid_positions.size() > 0:
|
if valid_positions.size() > 0:
|
||||||
# Use deterministic seed for key placement (ensures same position on host and clients)
|
# Use deterministic seed for key placement (ensures same position on host and clients)
|
||||||
@@ -9911,8 +10505,8 @@ func _connect_door_to_room_trigger(door: Node):
|
|||||||
if trigger_room and not trigger_room.is_empty() and \
|
if trigger_room and not trigger_room.is_empty() and \
|
||||||
trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \
|
trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \
|
||||||
trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h:
|
trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h:
|
||||||
# Connect door to trigger
|
# Connect door to trigger (avoid duplicate if room_trigger._find_room_entities already added it)
|
||||||
door.room_trigger_area = trigger
|
door.room_trigger_area = trigger
|
||||||
# Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd)
|
if door not in trigger.doors_in_room:
|
||||||
trigger.doors_in_room.append(door)
|
trigger.doors_in_room.append(door)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ var chest_closed_frame: int = -1
|
|||||||
var chest_opened_frame: int = -1
|
var chest_opened_frame: int = -1
|
||||||
var is_chest_opened: bool = false
|
var is_chest_opened: bool = false
|
||||||
|
|
||||||
|
# Hidden chest: invisible until detected via perception
|
||||||
|
var is_hidden: bool = false
|
||||||
|
var is_detected: bool = false
|
||||||
|
var player_detection_attempts: Dictionary = {} # peer_id -> true once they've attempted
|
||||||
|
var player_detection_fail_time: Dictionary = {} # peer_id -> Time.get_ticks_msec() of last failed roll (retry only after 60s)
|
||||||
|
const HIDDEN_CHEST_RETRY_COOLDOWN_MS: int = 60000 # 60 seconds before allowing another perception check after a fail
|
||||||
|
|
||||||
# Network sync timer
|
# Network sync timer
|
||||||
var sync_timer: float = 0.0
|
var sync_timer: float = 0.0
|
||||||
var sync_interval: float = 0.05 # Sync 20 times per second
|
var sync_interval: float = 0.05 # Sync 20 times per second
|
||||||
@@ -73,6 +80,94 @@ func _ready():
|
|||||||
shadow.modulate = Color(0, 0, 0, 0.5)
|
shadow.modulate = Color(0, 0, 0, 0.5)
|
||||||
shadow.z_index = -1
|
shadow.z_index = -1
|
||||||
|
|
||||||
|
# Hidden chest: apply initial state (game_world may set is_hidden after we're added)
|
||||||
|
call_deferred("_apply_hidden_state_if_needed")
|
||||||
|
|
||||||
|
func _apply_hidden_state_if_needed() -> void:
|
||||||
|
if is_hidden and not is_detected:
|
||||||
|
_apply_hidden_state()
|
||||||
|
|
||||||
|
func _apply_hidden_state() -> void:
|
||||||
|
if is_hidden and not is_detected:
|
||||||
|
if sprite:
|
||||||
|
sprite.modulate.a = 0.0
|
||||||
|
if sprite_above:
|
||||||
|
sprite_above.modulate.a = 0.0
|
||||||
|
if shadow:
|
||||||
|
shadow.visible = false
|
||||||
|
# Enable detection area and connect if not already
|
||||||
|
var det = get_node_or_null("DetectionArea")
|
||||||
|
if det:
|
||||||
|
if not det.body_entered.is_connected(_on_detection_area_body_entered):
|
||||||
|
det.body_entered.connect(_on_detection_area_body_entered)
|
||||||
|
if not det.body_exited.is_connected(_on_detection_area_body_exited):
|
||||||
|
det.body_exited.connect(_on_detection_area_body_exited)
|
||||||
|
det.monitoring = true
|
||||||
|
else:
|
||||||
|
if sprite:
|
||||||
|
sprite.modulate.a = 1.0
|
||||||
|
if sprite_above:
|
||||||
|
sprite_above.modulate.a = 1.0
|
||||||
|
if shadow:
|
||||||
|
shadow.visible = true
|
||||||
|
|
||||||
|
func _on_detection_area_body_entered(body: Node) -> void:
|
||||||
|
if not body.is_in_group("player"):
|
||||||
|
return
|
||||||
|
if object_type != "Chest" or not is_hidden or is_detected:
|
||||||
|
return
|
||||||
|
var peer_id = body.get_multiplayer_authority() if "get_multiplayer_authority" in body else 0
|
||||||
|
# If this player already attempted and failed, allow retry only after 60 seconds
|
||||||
|
if player_detection_attempts.has(peer_id):
|
||||||
|
var fail_ms: int = player_detection_fail_time.get(peer_id, 0)
|
||||||
|
if fail_ms > 0 and (Time.get_ticks_msec() - fail_ms) < HIDDEN_CHEST_RETRY_COOLDOWN_MS:
|
||||||
|
return
|
||||||
|
# Mark that this player is attempting (or retrying after cooldown)
|
||||||
|
player_detection_attempts[peer_id] = true
|
||||||
|
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer():
|
||||||
|
_roll_hidden_chest_perception(body)
|
||||||
|
|
||||||
|
func _on_detection_area_body_exited(_body: Node) -> void:
|
||||||
|
# No longer erase attempts on exit; retry only after 60s cooldown (checked on re-enter)
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _roll_hidden_chest_perception(player: Node) -> void:
|
||||||
|
if not player or not player.character_stats:
|
||||||
|
return
|
||||||
|
var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per")
|
||||||
|
var roll = randi() % 20 + 1
|
||||||
|
var total = roll + int(per_stat / 2) - 5
|
||||||
|
var dc = 13 # Slightly easier so detection is noticeable (was 15)
|
||||||
|
if total >= dc:
|
||||||
|
_detect_hidden_chest(player)
|
||||||
|
else:
|
||||||
|
var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
|
||||||
|
player_detection_fail_time[peer_id] = Time.get_ticks_msec()
|
||||||
|
|
||||||
|
func _detect_hidden_chest(detecting_player: Node) -> void:
|
||||||
|
is_detected = true
|
||||||
|
_apply_hidden_state()
|
||||||
|
# Spawn detected effect (synced)
|
||||||
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if game_world and game_world.has_method("spawn_detected_effect_at"):
|
||||||
|
game_world.spawn_detected_effect_at(global_position)
|
||||||
|
# Alert and sound for the detecting player only (SfxAhaa + SfxSecretFound)
|
||||||
|
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_secret_chest_detected"):
|
||||||
|
if detecting_player.is_multiplayer_authority():
|
||||||
|
detecting_player._on_secret_chest_detected()
|
||||||
|
else:
|
||||||
|
detecting_player._on_secret_chest_detected.rpc_id(detecting_player.get_multiplayer_authority())
|
||||||
|
# Sync to all clients
|
||||||
|
if multiplayer.has_multiplayer_peer() and is_inside_tree():
|
||||||
|
var game_world_sync = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if game_world_sync and game_world_sync.has_method("_sync_hidden_chest_detected_by_name"):
|
||||||
|
game_world_sync._sync_hidden_chest_detected_by_name.rpc(name)
|
||||||
|
|
||||||
|
func _sync_hidden_chest_detected() -> void:
|
||||||
|
# Called on clients when server detected this hidden chest
|
||||||
|
is_detected = true
|
||||||
|
_apply_hidden_state()
|
||||||
|
|
||||||
func _physics_process(delta):
|
func _physics_process(delta):
|
||||||
# All clients simulate physics locally for smooth visuals
|
# All clients simulate physics locally for smooth visuals
|
||||||
# Initial throw state is synced via player's _sync_throw RPC
|
# Initial throw state is synced via player's _sync_throw RPC
|
||||||
@@ -81,13 +176,15 @@ func _physics_process(delta):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not is_frozen:
|
if not is_frozen:
|
||||||
# Fallout: sink and disappear when on ground (not held, not airborne)
|
# Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink.
|
||||||
if not is_airborne and position_z <= 0.0:
|
if not is_airborne and position_z <= 0.0 and object_type != "Pillar":
|
||||||
var gw = get_tree().get_first_node_in_group("game_world")
|
var gw = get_tree().get_first_node_in_group("game_world")
|
||||||
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
|
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
|
||||||
if not falling_into_fallout:
|
if not falling_into_fallout:
|
||||||
falling_into_fallout = true
|
falling_into_fallout = true
|
||||||
fallout_sink_progress = 1.0
|
fallout_sink_progress = 1.0
|
||||||
|
if has_node("SfxFall"):
|
||||||
|
$SfxFall.play()
|
||||||
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
|
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
|
||||||
if fallout_sink_progress <= 0.0:
|
if fallout_sink_progress <= 0.0:
|
||||||
queue_free()
|
queue_free()
|
||||||
@@ -388,6 +485,9 @@ func _is_wall_collider(collider) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
func can_be_grabbed() -> bool:
|
func can_be_grabbed() -> bool:
|
||||||
|
# Hidden chest cannot be opened until detected
|
||||||
|
if object_type == "Chest" and is_hidden and not is_detected:
|
||||||
|
return false
|
||||||
return is_grabbable and not is_being_held
|
return is_grabbable and not is_being_held
|
||||||
|
|
||||||
func _get_configured_object_type() -> String:
|
func _get_configured_object_type() -> String:
|
||||||
@@ -453,6 +553,9 @@ func take_fire_damage(amount: float, _attacker_position: Vector2) -> void:
|
|||||||
|
|
||||||
func take_damage(amount: float, _from_position: Vector2) -> void:
|
func take_damage(amount: float, _from_position: Vector2) -> void:
|
||||||
"""Generic damage from bomb, frost spike, etc. Any destroyable object."""
|
"""Generic damage from bomb, frost spike, etc. Any destroyable object."""
|
||||||
|
# Hidden chest cannot be attacked until detected (must be revealed first)
|
||||||
|
if object_type == "Chest" and is_hidden and not is_detected:
|
||||||
|
return
|
||||||
if not is_destroyable or is_broken:
|
if not is_destroyable or is_broken:
|
||||||
return
|
return
|
||||||
health -= amount
|
health -= amount
|
||||||
@@ -471,6 +574,9 @@ func take_damage(amount: float, _from_position: Vector2) -> void:
|
|||||||
_break_into_pieces()
|
_break_into_pieces()
|
||||||
|
|
||||||
func on_grabbed(by_player):
|
func on_grabbed(by_player):
|
||||||
|
# Hidden chest cannot be opened until detected
|
||||||
|
if object_type == "Chest" and is_hidden and not is_detected:
|
||||||
|
return
|
||||||
# Special handling for chests - open instead of grab
|
# Special handling for chests - open instead of grab
|
||||||
if object_type == "Chest" and not is_chest_opened:
|
if object_type == "Chest" and not is_chest_opened:
|
||||||
# In multiplayer, send RPC to server if client is opening
|
# In multiplayer, send RPC to server if client is opening
|
||||||
@@ -807,6 +913,13 @@ func _open_chest(by_player: Node = null):
|
|||||||
return
|
return
|
||||||
$SfxOpenChest.play()
|
$SfxOpenChest.play()
|
||||||
is_chest_opened = true
|
is_chest_opened = true
|
||||||
|
# Remove reveal effect when opening a hidden chest (was detected, now opened)
|
||||||
|
if is_hidden:
|
||||||
|
var gw = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if gw and gw.has_method("remove_detected_effect_at_position"):
|
||||||
|
gw.remove_detected_effect_at_position(global_position)
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and gw.has_method("_rpc_to_ready_peers"):
|
||||||
|
gw._rpc_to_ready_peers("_sync_remove_detected_effect_at_position", [global_position.x, global_position.y])
|
||||||
|
|
||||||
# Track opened chest for syncing to new clients
|
# Track opened chest for syncing to new clients
|
||||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||||
@@ -860,12 +973,19 @@ func _open_chest(by_player: Node = null):
|
|||||||
if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON:
|
if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON:
|
||||||
candidates.append(item_id)
|
candidates.append(item_id)
|
||||||
|
|
||||||
# Select random item from candidates using deterministic RNG
|
# Chance for empty chest (no item, no coin, no loot)
|
||||||
var random_item_id = candidates[chest_rng.randi() % candidates.size()] if not candidates.is_empty() else null
|
var empty_roll = chest_rng.randf()
|
||||||
|
var is_empty_chest = (empty_roll < 0.18)
|
||||||
|
|
||||||
|
# Select random item from candidates using deterministic RNG (null if empty)
|
||||||
|
var random_item_id = null
|
||||||
|
if not is_empty_chest and not candidates.is_empty():
|
||||||
|
random_item_id = candidates[chest_rng.randi() % candidates.size()]
|
||||||
var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null
|
var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null
|
||||||
|
|
||||||
# CRITICAL: Instantly give item to player instead of spawning loot object
|
# CRITICAL: Instantly give item to player instead of spawning loot object (or show EMPTY CHEST)
|
||||||
if by_player and is_instance_valid(by_player) and by_player.is_in_group("player") and chest_item:
|
if by_player and is_instance_valid(by_player) and by_player.is_in_group("player"):
|
||||||
|
if chest_item:
|
||||||
# Add item to player inventory
|
# Add item to player inventory
|
||||||
if by_player.character_stats:
|
if by_player.character_stats:
|
||||||
var was_encumbered = by_player.character_stats.is_over_encumbered()
|
var was_encumbered = by_player.character_stats.is_over_encumbered()
|
||||||
@@ -892,25 +1012,19 @@ func _open_chest(by_player: Node = null):
|
|||||||
_show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
|
_show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
|
||||||
else:
|
else:
|
||||||
_show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item)
|
_show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item)
|
||||||
|
# Play get-item sound when receiving inventory item from chest
|
||||||
# Play chest open sound
|
if has_node("SfxGetItemFromChest"):
|
||||||
if has_node("SfxChestOpen"):
|
$SfxGetItemFromChest.play()
|
||||||
$SfxChestOpen.play()
|
|
||||||
|
|
||||||
print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name)
|
print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name)
|
||||||
|
|
||||||
# Sync chest opening visual to all clients (item already given on server)
|
# Sync chest opening visual to all clients (item already given on server)
|
||||||
if multiplayer.has_multiplayer_peer():
|
if multiplayer.has_multiplayer_peer():
|
||||||
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
|
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
|
||||||
# Reuse game_world from earlier in the function
|
|
||||||
if game_world and game_world.has_method("_rpc_to_ready_peers"):
|
if game_world and game_world.has_method("_rpc_to_ready_peers"):
|
||||||
var chest_name = name
|
var chest_name = name
|
||||||
if has_meta("object_index"):
|
if has_meta("object_index"):
|
||||||
chest_name = "InteractableObject_%d" % get_meta("object_index")
|
chest_name = "InteractableObject_%d" % get_meta("object_index")
|
||||||
# Sync chest open visual with item_data so clients can show the floating text
|
|
||||||
var item_data = chest_item.save() if chest_item else {}
|
var item_data = chest_item.save() if chest_item else {}
|
||||||
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data])
|
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data])
|
||||||
# Sync inventory+equipment to joiner (server added item; joiner's client must apply)
|
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
var owner_id = by_player.get_multiplayer_authority()
|
var owner_id = by_player.get_multiplayer_authority()
|
||||||
if owner_id != 1 and owner_id != multiplayer.get_unique_id():
|
if owner_id != 1 and owner_id != multiplayer.get_unique_id():
|
||||||
@@ -923,6 +1037,17 @@ func _open_chest(by_player: Node = null):
|
|||||||
equip_data[slot_name] = eq.save() if eq else null
|
equip_data[slot_name] = eq.save() if eq else null
|
||||||
if by_player.has_method("_apply_inventory_and_equipment_from_server"):
|
if by_player.has_method("_apply_inventory_and_equipment_from_server"):
|
||||||
by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
||||||
|
else:
|
||||||
|
# Empty chest: show "EMPTY CHEST" floating text
|
||||||
|
_show_item_pickup_notification(by_player, "EMPTY CHEST", Color(0.5, 0.5, 0.5))
|
||||||
|
print(name, " opened by ", by_player.name, " - empty chest")
|
||||||
|
if multiplayer.has_multiplayer_peer():
|
||||||
|
var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0
|
||||||
|
if game_world and game_world.has_method("_rpc_to_ready_peers"):
|
||||||
|
var chest_name = name
|
||||||
|
if has_meta("object_index"):
|
||||||
|
chest_name = "InteractableObject_%d" % get_meta("object_index")
|
||||||
|
game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "empty", player_peer_id, {}])
|
||||||
else:
|
else:
|
||||||
push_error("Chest: ERROR - No valid player to give item to!")
|
push_error("Chest: ERROR - No valid player to give item to!")
|
||||||
|
|
||||||
@@ -991,6 +1116,8 @@ func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0, i
|
|||||||
_show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
|
_show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item)
|
||||||
else:
|
else:
|
||||||
_show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0, chest_item)
|
_show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0, chest_item)
|
||||||
|
elif loot_type_str == "empty":
|
||||||
|
_show_item_pickup_notification(player, "EMPTY CHEST", Color(0.5, 0.5, 0.5))
|
||||||
else:
|
else:
|
||||||
# Fallback to old loot type system (for backwards compatibility)
|
# Fallback to old loot type system (for backwards compatibility)
|
||||||
match loot_type_str:
|
match loot_type_str:
|
||||||
|
|||||||
@@ -257,18 +257,19 @@ func _update_stats():
|
|||||||
str(char_stats.baseStats.lck) + "\n" + \
|
str(char_stats.baseStats.lck) + "\n" + \
|
||||||
str(char_stats.baseStats.get("per", 10))
|
str(char_stats.baseStats.get("per", 10))
|
||||||
|
|
||||||
# Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit% (XP/Coin moved to exp meter & coin UI)
|
# Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit%, Dodge% (XP/Coin moved to exp meter & coin UI)
|
||||||
if label_derived_stats:
|
if label_derived_stats:
|
||||||
label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%"
|
label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%\nDodge%"
|
||||||
if label_derived_stats_value:
|
if label_derived_stats_value:
|
||||||
label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%" % [
|
label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%\n%.1f%%" % [
|
||||||
char_stats.damage,
|
char_stats.damage,
|
||||||
char_stats.defense,
|
char_stats.defense,
|
||||||
char_stats.move_speed,
|
char_stats.move_speed,
|
||||||
char_stats.attack_speed,
|
char_stats.attack_speed,
|
||||||
char_stats.sight,
|
char_stats.sight,
|
||||||
char_stats.spell_amp,
|
char_stats.spell_amp,
|
||||||
char_stats.crit_chance
|
char_stats.crit_chance,
|
||||||
|
char_stats.dodge_chance * 100.0
|
||||||
]
|
]
|
||||||
|
|
||||||
# HP bar
|
# HP bar
|
||||||
@@ -1679,6 +1680,9 @@ func _use_consumable_item(item: Item):
|
|||||||
if item.modifiers.has("mp"):
|
if item.modifiers.has("mp"):
|
||||||
var mana_amount = item.modifiers["mp"]
|
var mana_amount = item.modifiers["mp"]
|
||||||
char_stats.restore_mana(mana_amount)
|
char_stats.restore_mana(mana_amount)
|
||||||
|
# Dodge potion (and any consumable with dodge_chance + duration): apply temporary dodge buff
|
||||||
|
if item.modifiers.has("dodge_chance") and item.duration > 0:
|
||||||
|
char_stats.add_buff_dodge_chance(item.modifiers["dodge_chance"], item.duration)
|
||||||
|
|
||||||
var index = char_stats.inventory.find(item)
|
var index = char_stats.inventory.find(item)
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
@@ -1845,3 +1849,6 @@ func _lock_player_controls(lock: bool):
|
|||||||
var local_players = player_manager.get_local_players()
|
var local_players = player_manager.get_local_players()
|
||||||
for player in local_players:
|
for player in local_players:
|
||||||
player.controls_disabled = lock
|
player.controls_disabled = lock
|
||||||
|
if lock:
|
||||||
|
# Stop movement immediately when opening inventory (physics may have already run this frame)
|
||||||
|
player.velocity = Vector2.ZERO
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ var item: Item = null # Item instance (for LootType.ITEM)
|
|||||||
# Quantity badge for items with quantity > 1
|
# Quantity badge for items with quantity > 1
|
||||||
var quantity_badge: Label = null
|
var quantity_badge: Label = null
|
||||||
|
|
||||||
# Fallout: sink and disappear (no recovery)
|
# Fallout: sink and disappear (no recovery); keys respawn on nearest safe tile
|
||||||
var falling_into_fallout: bool = false
|
var falling_into_fallout: bool = false
|
||||||
var fallout_sink_progress: float = 1.0
|
var fallout_sink_progress: float = 1.0
|
||||||
const FALLOUT_SINK_DURATION: float = 0.4
|
const FALLOUT_SINK_DURATION: float = 0.4
|
||||||
@@ -234,6 +234,8 @@ func _physics_process(delta):
|
|||||||
if not falling_into_fallout:
|
if not falling_into_fallout:
|
||||||
falling_into_fallout = true
|
falling_into_fallout = true
|
||||||
fallout_sink_progress = 1.0
|
fallout_sink_progress = 1.0
|
||||||
|
if has_node("SfxFall"):
|
||||||
|
$SfxFall.play()
|
||||||
# Lock to center of fallout tile and stop all x/y movement
|
# Lock to center of fallout tile and stop all x/y movement
|
||||||
if gw.has_method("_get_tile_center_at"):
|
if gw.has_method("_get_tile_center_at"):
|
||||||
global_position = gw._get_tile_center_at(global_position)
|
global_position = gw._get_tile_center_at(global_position)
|
||||||
@@ -244,6 +246,19 @@ func _physics_process(delta):
|
|||||||
pickup_area.monitorable = false
|
pickup_area.monitorable = false
|
||||||
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
|
fallout_sink_progress -= delta / FALLOUT_SINK_DURATION
|
||||||
if fallout_sink_progress <= 0.0:
|
if fallout_sink_progress <= 0.0:
|
||||||
|
# Keys respawn on a safe tile (like player); other loot is removed
|
||||||
|
if loot_type == LootType.KEY:
|
||||||
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if game_world and game_world.has_method("_get_nearest_safe_tile_center"):
|
||||||
|
var safe_pos = game_world._get_nearest_safe_tile_center(global_position)
|
||||||
|
_respawn_key_at_safe_position(safe_pos)
|
||||||
|
if multiplayer.has_multiplayer_peer() and is_inside_tree():
|
||||||
|
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
|
||||||
|
if game_world.has_method("_rpc_to_ready_peers"):
|
||||||
|
game_world._rpc_to_ready_peers("_sync_key_respawn", [loot_id, safe_pos])
|
||||||
|
else:
|
||||||
|
queue_free()
|
||||||
|
else:
|
||||||
# Sync removal to clients so joiner sees loot disappear (same as pickup)
|
# Sync removal to clients so joiner sees loot disappear (same as pickup)
|
||||||
if multiplayer.has_multiplayer_peer() and is_inside_tree():
|
if multiplayer.has_multiplayer_peer() and is_inside_tree():
|
||||||
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
|
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
|
||||||
@@ -447,6 +462,20 @@ func _animate_coin(delta):
|
|||||||
var frame = int(coin_anim_time) % 6
|
var frame = int(coin_anim_time) % 6
|
||||||
sprite.frame = frame
|
sprite.frame = frame
|
||||||
|
|
||||||
|
func _respawn_key_at_safe_position(safe_pos: Vector2):
|
||||||
|
# Reposition key on a safe (non-fallout) tile and reset state so it can be picked up again
|
||||||
|
global_position = safe_pos
|
||||||
|
falling_into_fallout = false
|
||||||
|
fallout_sink_progress = 1.0
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
velocity_z = 0.0
|
||||||
|
position_z = SPAWN_POSITION_Z
|
||||||
|
is_airborne = false
|
||||||
|
scale = Vector2.ONE
|
||||||
|
if pickup_area:
|
||||||
|
pickup_area.monitoring = true
|
||||||
|
pickup_area.monitorable = true
|
||||||
|
|
||||||
func _on_pickup_area_body_entered(body):
|
func _on_pickup_area_body_entered(body):
|
||||||
if falling_into_fallout:
|
if falling_into_fallout:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ var is_knocked_back: bool = false
|
|||||||
var knockback_time: float = 0.0
|
var knockback_time: float = 0.0
|
||||||
var knockback_duration: float = 0.3 # How long knockback lasts
|
var knockback_duration: float = 0.3 # How long knockback lasts
|
||||||
|
|
||||||
|
# Web net (boss): when netted_by_web != null, player is stuck and cannot attack with main weapon
|
||||||
|
var netted_by_web: Node = null
|
||||||
|
var netted_overlay_sprite: Sprite2D = null # Frame 679 for netted visual
|
||||||
|
|
||||||
# Attack/Combat
|
# Attack/Combat
|
||||||
var can_attack: bool = true
|
var can_attack: bool = true
|
||||||
var attack_cooldown: float = 0.0 # No cooldown - instant attacks!
|
var attack_cooldown: float = 0.0 # No cooldown - instant attacks!
|
||||||
@@ -182,6 +186,7 @@ const HELD_POSITION_Z: float = 12.0 # Z height when held/lifted (above ground; i
|
|||||||
@onready var sfx_die = $SfxDie
|
@onready var sfx_die = $SfxDie
|
||||||
@onready var sfx_look_out = $SfxLookOut
|
@onready var sfx_look_out = $SfxLookOut
|
||||||
@onready var sfx_ahaa = $SfxAhaa
|
@onready var sfx_ahaa = $SfxAhaa
|
||||||
|
@onready var sfx_secret_found = $SfxSecretFound
|
||||||
|
|
||||||
# Alert indicator (exclamation mark)
|
# Alert indicator (exclamation mark)
|
||||||
var alert_indicator: Sprite2D = null
|
var alert_indicator: Sprite2D = null
|
||||||
@@ -1493,32 +1498,23 @@ func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool:
|
|||||||
placed_shape_transform = Transform2D(0.0, place_pos)
|
placed_shape_transform = Transform2D(0.0, place_pos)
|
||||||
|
|
||||||
# Check if the placed object's collision shape would collide with anything
|
# Check if the placed object's collision shape would collide with anything
|
||||||
# This includes: walls, other objects, and players
|
# This includes: walls, other objects, and players (not Area2D triggers - those don't block placement)
|
||||||
var params = PhysicsShapeQueryParameters2D.new()
|
var params = PhysicsShapeQueryParameters2D.new()
|
||||||
params.shape = placed_shape
|
params.shape = placed_shape
|
||||||
params.transform = placed_shape_transform
|
params.transform = placed_shape_transform
|
||||||
params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64)
|
params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64)
|
||||||
|
params.collide_with_areas = false # Only solid bodies block; ignore trigger areas (e.g. door key zones)
|
||||||
|
params.collide_with_bodies = true
|
||||||
|
|
||||||
# CRITICAL: Exclude self, the object being placed, and make sure to exclude it properly
|
# CRITICAL: Exclude using RIDs so the physics engine actually excludes them (Node refs may not work)
|
||||||
# The object might still be in the scene tree with collision disabled, so we need to exclude it
|
var exclude_list: Array[RID] = [get_rid()]
|
||||||
var exclude_list = [self]
|
if placed_obj and is_instance_valid(placed_obj) and placed_obj is CollisionObject2D:
|
||||||
if placed_obj and is_instance_valid(placed_obj):
|
exclude_list.append(placed_obj.get_rid())
|
||||||
exclude_list.append(placed_obj)
|
|
||||||
params.exclude = exclude_list
|
params.exclude = exclude_list
|
||||||
|
|
||||||
# Test the actual collision shape at the placement position
|
# Test the actual collision shape at the placement position
|
||||||
var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions
|
var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions
|
||||||
|
|
||||||
# Debug: Log what we found
|
|
||||||
if hits.size() > 0:
|
|
||||||
print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos)
|
|
||||||
for i in min(hits.size(), 3): # Log first 3 collisions
|
|
||||||
var hit = hits[i]
|
|
||||||
if hit.has("collider"):
|
|
||||||
print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")")
|
|
||||||
if hit.has("rid"):
|
|
||||||
print(" - RID: ", hit.rid)
|
|
||||||
|
|
||||||
# If any collisions found, placement is invalid
|
# If any collisions found, placement is invalid
|
||||||
return hits.size() == 0
|
return hits.size() == 0
|
||||||
|
|
||||||
@@ -1964,6 +1960,8 @@ func _physics_process(delta):
|
|||||||
const MANA_REGEN_RATE = 2.0 # mana per second
|
const MANA_REGEN_RATE = 2.0 # mana per second
|
||||||
if character_stats.mp < character_stats.maxmp:
|
if character_stats.mp < character_stats.maxmp:
|
||||||
character_stats.restore_mana(MANA_REGEN_RATE * delta)
|
character_stats.restore_mana(MANA_REGEN_RATE * delta)
|
||||||
|
# Tick down temporary buffs (e.g. dodge potion)
|
||||||
|
character_stats.tick_buffs(delta)
|
||||||
|
|
||||||
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
|
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
|
||||||
if is_charging_spell:
|
if is_charging_spell:
|
||||||
@@ -2496,8 +2494,8 @@ func _handle_input():
|
|||||||
if new_direction != current_direction:
|
if new_direction != current_direction:
|
||||||
current_direction = new_direction
|
current_direction = new_direction
|
||||||
_update_cone_light_rotation()
|
_update_cone_light_rotation()
|
||||||
elif is_pushing:
|
elif is_pushing or (held_object and not is_lifting):
|
||||||
# Keep locked direction when pushing
|
# Keep direction from when grab started (don't turn to face the object)
|
||||||
if push_direction_locked != current_direction:
|
if push_direction_locked != current_direction:
|
||||||
current_direction = push_direction_locked as Direction
|
current_direction = push_direction_locked as Direction
|
||||||
_update_cone_light_rotation()
|
_update_cone_light_rotation()
|
||||||
@@ -2539,9 +2537,10 @@ func _handle_input():
|
|||||||
elif is_lifting:
|
elif is_lifting:
|
||||||
if current_animation != "LIFT" and current_animation != "IDLE_HOLD":
|
if current_animation != "LIFT" and current_animation != "IDLE_HOLD":
|
||||||
_set_animation("IDLE_HOLD")
|
_set_animation("IDLE_HOLD")
|
||||||
elif is_pushing:
|
elif is_pushing or (held_object and not is_lifting):
|
||||||
|
if is_pushing:
|
||||||
_set_animation("IDLE_PUSH")
|
_set_animation("IDLE_PUSH")
|
||||||
# Keep locked direction when pushing
|
# Keep direction from when grab started
|
||||||
if push_direction_locked != current_direction:
|
if push_direction_locked != current_direction:
|
||||||
current_direction = push_direction_locked as Direction
|
current_direction = push_direction_locked as Direction
|
||||||
_update_cone_light_rotation()
|
_update_cone_light_rotation()
|
||||||
@@ -2598,8 +2597,8 @@ func _handle_input():
|
|||||||
if character_stats and character_stats.is_over_encumbered():
|
if character_stats and character_stats.is_over_encumbered():
|
||||||
current_speed = base_speed * 0.25
|
current_speed = base_speed * 0.25
|
||||||
|
|
||||||
# Lock movement if movement_lock_timer is active or reviving a corpse
|
# Lock movement if movement_lock_timer is active, reviving a corpse, or netted by web
|
||||||
if movement_lock_timer > 0.0 or is_reviving:
|
if movement_lock_timer > 0.0 or is_reviving or netted_by_web:
|
||||||
velocity = Vector2.ZERO
|
velocity = Vector2.ZERO
|
||||||
else:
|
else:
|
||||||
velocity = input_vector * current_speed
|
velocity = input_vector * current_speed
|
||||||
@@ -2623,6 +2622,32 @@ func _handle_walking_sfx():
|
|||||||
if sfx_walk and sfx_walk.playing:
|
if sfx_walk and sfx_walk.playing:
|
||||||
sfx_walk.stop()
|
sfx_walk.stop()
|
||||||
|
|
||||||
|
func _web_net_apply(web_node: Node) -> void:
|
||||||
|
netted_by_web = web_node
|
||||||
|
|
||||||
|
func _web_net_release(_web_node: Node) -> void:
|
||||||
|
netted_by_web = null
|
||||||
|
_web_net_show_netted_frame(false)
|
||||||
|
|
||||||
|
func _web_net_show_netted_frame(show_net: bool) -> void:
|
||||||
|
if show_net:
|
||||||
|
if netted_overlay_sprite == null:
|
||||||
|
netted_overlay_sprite = Sprite2D.new()
|
||||||
|
var tex = load("res://assets/gfx/fx/shade_spell_effects.png") as Texture2D
|
||||||
|
if tex:
|
||||||
|
netted_overlay_sprite.texture = tex
|
||||||
|
netted_overlay_sprite.hframes = 105
|
||||||
|
netted_overlay_sprite.vframes = 79
|
||||||
|
netted_overlay_sprite.frame = 679
|
||||||
|
netted_overlay_sprite.centered = true
|
||||||
|
netted_overlay_sprite.z_index = 5
|
||||||
|
add_child(netted_overlay_sprite)
|
||||||
|
if netted_overlay_sprite:
|
||||||
|
netted_overlay_sprite.visible = true
|
||||||
|
else:
|
||||||
|
if netted_overlay_sprite:
|
||||||
|
netted_overlay_sprite.visible = false
|
||||||
|
|
||||||
func _handle_interactions():
|
func _handle_interactions():
|
||||||
var grab_button_down = false
|
var grab_button_down = false
|
||||||
var grab_just_pressed = false
|
var grab_just_pressed = false
|
||||||
@@ -3192,7 +3217,7 @@ func _handle_interactions():
|
|||||||
|
|
||||||
# Handle bow charging
|
# Handle bow charging
|
||||||
if has_bow_and_arrows and not is_lifting and not is_pushing:
|
if has_bow_and_arrows and not is_lifting and not is_pushing:
|
||||||
if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing:
|
if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing and not netted_by_web:
|
||||||
if !$SfxBuckleBow.playing:
|
if !$SfxBuckleBow.playing:
|
||||||
$SfxBuckleBow.play()
|
$SfxBuckleBow.play()
|
||||||
# Start charging bow
|
# Start charging bow
|
||||||
@@ -3260,7 +3285,7 @@ func _handle_interactions():
|
|||||||
|
|
||||||
# Normal attack (non-bow or no arrows)
|
# Normal attack (non-bow or no arrows)
|
||||||
# Also allow throwing when lifting (even if bow is equipped). Block during spawn fall.
|
# Also allow throwing when lifting (even if bow is equipped). Block during spawn fall.
|
||||||
if attack_just_pressed and can_attack and not spawn_landing:
|
if attack_just_pressed and can_attack and not spawn_landing and not netted_by_web:
|
||||||
if is_lifting:
|
if is_lifting:
|
||||||
# Attack while lifting -> throw immediately in facing direction
|
# Attack while lifting -> throw immediately in facing direction
|
||||||
_force_throw_held_object(facing_direction_vector)
|
_force_throw_held_object(facing_direction_vector)
|
||||||
@@ -3382,11 +3407,12 @@ func _try_grab():
|
|||||||
# Store the distance from player to object when grabbed (for placement)
|
# Store the distance from player to object when grabbed (for placement)
|
||||||
grab_distance = global_position.distance_to(closest_body.global_position)
|
grab_distance = global_position.distance_to(closest_body.global_position)
|
||||||
|
|
||||||
# Calculate push axis from grab direction (but don't move the object yet)
|
# Use player's current facing when grab started (do not turn to face the object)
|
||||||
var grab_direction = grab_offset.normalized()
|
var grab_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized()
|
||||||
if grab_direction.length() < 0.1:
|
if grab_direction.length() < 0.1:
|
||||||
grab_direction = last_movement_direction
|
grab_direction = Vector2.DOWN
|
||||||
push_axis = _snap_to_8_directions(grab_direction)
|
push_axis = _snap_to_8_directions(grab_direction)
|
||||||
|
push_direction_locked = _get_direction_from_vector(push_axis) as Direction
|
||||||
|
|
||||||
# Disable collision with players and other objects when grabbing
|
# Disable collision with players and other objects when grabbing
|
||||||
# But keep collision with walls (layer 7) enabled for pushing
|
# But keep collision with walls (layer 7) enabled for pushing
|
||||||
@@ -3503,15 +3529,11 @@ func _start_pushing():
|
|||||||
is_pushing = true
|
is_pushing = true
|
||||||
is_lifting = false
|
is_lifting = false
|
||||||
|
|
||||||
# Lock to the direction we're facing when we start pushing
|
# Keep the direction we had when we started the grab (do not face the object)
|
||||||
var initial_direction = grab_offset.normalized()
|
var initial_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized()
|
||||||
if initial_direction.length() < 0.1:
|
if initial_direction.length() < 0.1:
|
||||||
initial_direction = last_movement_direction.normalized()
|
initial_direction = Vector2.DOWN
|
||||||
|
|
||||||
# Snap to one of 8 directions
|
|
||||||
push_axis = _snap_to_8_directions(initial_direction)
|
push_axis = _snap_to_8_directions(initial_direction)
|
||||||
|
|
||||||
# Lock the facing direction (for both animation and attacks)
|
|
||||||
push_direction_locked = _get_direction_from_vector(push_axis)
|
push_direction_locked = _get_direction_from_vector(push_axis)
|
||||||
facing_direction_vector = push_axis.normalized()
|
facing_direction_vector = push_axis.normalized()
|
||||||
|
|
||||||
@@ -4045,7 +4067,7 @@ func _place_down_object():
|
|||||||
print("Placed down ", placed_obj.name, " at ", place_pos)
|
print("Placed down ", placed_obj.name, " at ", place_pos)
|
||||||
|
|
||||||
func _perform_attack():
|
func _perform_attack():
|
||||||
if not can_attack or is_attacking or spawn_landing:
|
if not can_attack or is_attacking or spawn_landing or netted_by_web:
|
||||||
return
|
return
|
||||||
|
|
||||||
can_attack = false
|
can_attack = false
|
||||||
@@ -6387,6 +6409,13 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
|
|||||||
# Invulnerable during fallout sink (can't take damage from anything while falling)
|
# Invulnerable during fallout sink (can't take damage from anything while falling)
|
||||||
if fallout_state:
|
if fallout_state:
|
||||||
return
|
return
|
||||||
|
# Taking damage while webbed immediately frees you from the web
|
||||||
|
if netted_by_web:
|
||||||
|
var web = netted_by_web
|
||||||
|
netted_by_web = null
|
||||||
|
_web_net_show_netted_frame(false)
|
||||||
|
if web and is_instance_valid(web) and web.has_method("cut_by_attack"):
|
||||||
|
web.cut_by_attack(null)
|
||||||
|
|
||||||
# Cancel bow charging when taking damage
|
# Cancel bow charging when taking damage
|
||||||
if is_charging_bow:
|
if is_charging_bow:
|
||||||
@@ -6552,10 +6581,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
|
|||||||
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
|
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
|
||||||
_rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position])
|
_rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position])
|
||||||
|
|
||||||
# Check if dead - but wait for damage animation to play first
|
# Check if dead - below 1 HP must always trigger death (trap, etc.)
|
||||||
# Use small epsilon to handle floating point precision issues (HP might be 0.0000001 instead of exactly 0.0)
|
|
||||||
var health = character_stats.hp if character_stats else current_health
|
var health = character_stats.hp if character_stats else current_health
|
||||||
if health <= 0.001: # Use epsilon to catch values very close to 0
|
if health < 1.0:
|
||||||
if character_stats:
|
if character_stats:
|
||||||
character_stats.hp = 0.0 # Clamp to exactly 0
|
character_stats.hp = 0.0 # Clamp to exactly 0
|
||||||
else:
|
else:
|
||||||
@@ -6563,6 +6591,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
|
|||||||
is_dead = true # Set flag immediately to prevent more damage
|
is_dead = true # Set flag immediately to prevent more damage
|
||||||
# Wait a bit for damage animation and knockback to show
|
# Wait a bit for damage animation and knockback to show
|
||||||
await get_tree().create_timer(0.3).timeout
|
await get_tree().create_timer(0.3).timeout
|
||||||
|
if is_instance_valid(self) and is_dead:
|
||||||
_die()
|
_die()
|
||||||
|
|
||||||
func _die():
|
func _die():
|
||||||
@@ -7575,6 +7604,10 @@ func _on_level_up_stats(stats_increased: Array):
|
|||||||
if not character_stats:
|
if not character_stats:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Play level-up fanfare locally only when this player (you) gained the level
|
||||||
|
if is_multiplayer_authority() and has_node("SfxLevelUp"):
|
||||||
|
$SfxLevelUp.play()
|
||||||
|
|
||||||
# Stat name to display name mapping
|
# Stat name to display name mapping
|
||||||
var stat_display_names = {
|
var stat_display_names = {
|
||||||
"str": "STR",
|
"str": "STR",
|
||||||
@@ -7651,6 +7684,17 @@ func rpc_apply_corpse_knockback(dir_x: float, dir_y: float, force: float):
|
|||||||
is_knocked_back = true
|
is_knocked_back = true
|
||||||
knockback_time = 0.0
|
knockback_time = 0.0
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _on_attack_blocked_by_enemy(blocker_position: Vector2):
|
||||||
|
# Called when this player's attack was blocked by an enemy (e.g. humanoid with shield). Show BLOCKED and small knockback.
|
||||||
|
var dir_away = (global_position - blocker_position).normalized()
|
||||||
|
if dir_away.length() < 0.01:
|
||||||
|
dir_away = Vector2.RIGHT
|
||||||
|
velocity = dir_away * 75.0
|
||||||
|
is_knocked_back = true
|
||||||
|
knockback_time = 0.0
|
||||||
|
_show_damage_number(0.0, blocker_position, false, false, false, true)
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
@rpc("any_peer", "reliable")
|
||||||
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
|
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
|
||||||
# This RPC only syncs visual effects, not damage application
|
# This RPC only syncs visual effects, not damage application
|
||||||
@@ -7796,6 +7840,26 @@ func _sync_trap_detected_alert():
|
|||||||
if sfx_look_out:
|
if sfx_look_out:
|
||||||
sfx_look_out.play()
|
sfx_look_out.play()
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _on_cracked_floor_detected():
|
||||||
|
# Called when this player detects a cracked floor (perception roll success). Only the detecting player plays SfxLookOut and sees alert.
|
||||||
|
if not is_multiplayer_authority():
|
||||||
|
return
|
||||||
|
_show_alert_indicator()
|
||||||
|
if sfx_look_out:
|
||||||
|
sfx_look_out.play()
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _on_secret_chest_detected():
|
||||||
|
# Called when this player detects a hidden chest (perception roll success). Only the detecting player plays SfxAhaa + SfxSecretFound and sees alert.
|
||||||
|
if not is_multiplayer_authority():
|
||||||
|
return
|
||||||
|
_show_alert_indicator()
|
||||||
|
if sfx_ahaa:
|
||||||
|
sfx_ahaa.play()
|
||||||
|
if sfx_secret_found:
|
||||||
|
sfx_secret_found.play()
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
@rpc("any_peer", "reliable")
|
||||||
func _sync_exit_found_alert():
|
func _sync_exit_found_alert():
|
||||||
# Sync exit found alert to all clients
|
# Sync exit found alert to all clients
|
||||||
|
|||||||
@@ -15,15 +15,19 @@ var elapsed_time: float = 0.0
|
|||||||
var distance_traveled: float = 0.0
|
var distance_traveled: float = 0.0
|
||||||
var player_owner: Node = null
|
var player_owner: Node = null
|
||||||
var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup)
|
var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup)
|
||||||
|
var _cut_web_rpc_sent: bool = false
|
||||||
|
|
||||||
@onready var sprite = $Sprite2D
|
@onready var sprite = $Sprite2D
|
||||||
@onready var hit_area = $Area2D
|
@onready var hit_area = $Area2D
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
$SfxSwosh.play()
|
$SfxSwosh.play()
|
||||||
# Connect area signals (only if not already connected)
|
if hit_area:
|
||||||
if hit_area and not hit_area.body_entered.is_connected(_on_body_entered):
|
if not hit_area.body_entered.is_connected(_on_body_entered):
|
||||||
hit_area.body_entered.connect(_on_body_entered)
|
hit_area.body_entered.connect(_on_body_entered)
|
||||||
|
if not hit_area.area_entered.is_connected(_on_area_entered):
|
||||||
|
hit_area.area_entered.connect(_on_area_entered)
|
||||||
|
hit_area.collision_mask = hit_area.collision_mask | 8
|
||||||
|
|
||||||
func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0):
|
func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0):
|
||||||
travel_direction = direction.normalized()
|
travel_direction = direction.normalized()
|
||||||
@@ -52,6 +56,13 @@ func _physics_process(delta):
|
|||||||
current_speed -= deceleration * delta
|
current_speed -= deceleration * delta
|
||||||
current_speed = max(0.0, current_speed) # Don't go negative
|
current_speed = max(0.0, current_speed) # Don't go negative
|
||||||
|
|
||||||
|
# So other player can free netted teammate: notify server to cut web
|
||||||
|
if not _cut_web_rpc_sent and multiplayer.has_multiplayer_peer() and not multiplayer.is_server() and player_owner and is_instance_valid(player_owner) and elapsed_time >= 0.04:
|
||||||
|
_cut_web_rpc_sent = true
|
||||||
|
var gw = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if gw and gw.has_method("request_cut_web"):
|
||||||
|
gw.request_cut_web.rpc_id(1, global_position.x, global_position.y, 30.0, player_owner.get_multiplayer_authority())
|
||||||
|
|
||||||
# Move in travel direction
|
# Move in travel direction
|
||||||
var movement = travel_direction * current_speed * delta
|
var movement = travel_direction * current_speed * delta
|
||||||
global_position += movement
|
global_position += movement
|
||||||
@@ -62,6 +73,11 @@ func _physics_process(delta):
|
|||||||
if sprite:
|
if sprite:
|
||||||
sprite.modulate.a = alpha
|
sprite.modulate.a = alpha
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if area.has_method("cut_by_attack") and area.get("state") == "hit_player":
|
||||||
|
if player_owner and is_instance_valid(player_owner) and player_owner != area.get("hit_player"):
|
||||||
|
area.cut_by_attack(player_owner)
|
||||||
|
|
||||||
func _on_body_entered(body):
|
func _on_body_entered(body):
|
||||||
# Don't hit the owner
|
# Don't hit the owner
|
||||||
if body == player_owner:
|
if body == player_owner:
|
||||||
@@ -134,6 +150,9 @@ func _on_body_entered(body):
|
|||||||
else:
|
else:
|
||||||
# Client sends RPC to server
|
# Client sends RPC to server
|
||||||
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit)
|
game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit)
|
||||||
|
# Client: show damage number locally (boss/enemy won't show it on our view otherwise)
|
||||||
|
if game_world and game_world.has_method("show_damage_number_at_position") and not multiplayer.is_server():
|
||||||
|
game_world.show_damage_number_at_position(body.global_position, damage, is_crit)
|
||||||
else:
|
else:
|
||||||
# Fallback: try direct call (may fail if node path doesn't match)
|
# Fallback: try direct call (may fail if node path doesn't match)
|
||||||
var enemy_peer_id = body.get_multiplayer_authority()
|
var enemy_peer_id = body.get_multiplayer_authority()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -143,13 +143,18 @@ func _detect_trap(detecting_player: Node) -> void:
|
|||||||
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"):
|
if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"):
|
||||||
detecting_player._on_trap_detected()
|
detecting_player._on_trap_detected()
|
||||||
|
|
||||||
|
# Spawn detected effect at trap position (synced so all players see it; parented to trap so it can be removed when disarmed)
|
||||||
|
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
|
if game_world and game_world.has_method("spawn_detected_effect_at"):
|
||||||
|
game_world.spawn_detected_effect_at(global_position, name, "trap")
|
||||||
|
|
||||||
# Sync detection to all clients (including server with call_local)
|
# Sync detection to all clients (including server with call_local)
|
||||||
# CRITICAL: Validate trap is still valid before sending RPC
|
# CRITICAL: Validate trap is still valid before sending RPC
|
||||||
# Use GameWorld RPC to avoid node path issues
|
# Use GameWorld RPC to avoid node path issues
|
||||||
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
|
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
# Use GameWorld RPC with trap name instead of path
|
# Use GameWorld RPC with trap name instead of path
|
||||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
game_world = get_tree().get_first_node_in_group("game_world")
|
||||||
if game_world and game_world.has_method("_sync_trap_state_by_name"):
|
if game_world and game_world.has_method("_sync_trap_state_by_name"):
|
||||||
game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false
|
game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false
|
||||||
|
|
||||||
@@ -212,6 +217,13 @@ func _show_disarm_text(_player: Node) -> void:
|
|||||||
disarm_label.z_index = 100
|
disarm_label.z_index = 100
|
||||||
add_child(disarm_label)
|
add_child(disarm_label)
|
||||||
|
|
||||||
|
func _remove_detected_effect() -> void:
|
||||||
|
# Remove the blue "detected" effect when trap is disarmed (effect is parented to this trap)
|
||||||
|
for c in get_children():
|
||||||
|
if c.name == "DetectedEffect":
|
||||||
|
c.queue_free()
|
||||||
|
return
|
||||||
|
|
||||||
func _hide_disarm_text(_player: Node) -> void:
|
func _hide_disarm_text(_player: Node) -> void:
|
||||||
if disarm_label:
|
if disarm_label:
|
||||||
disarm_label.queue_free()
|
disarm_label.queue_free()
|
||||||
@@ -246,6 +258,12 @@ func _complete_disarm() -> void:
|
|||||||
if $SfxDisarming.playing:
|
if $SfxDisarming.playing:
|
||||||
$SfxDisarming.stop()
|
$SfxDisarming.stop()
|
||||||
|
|
||||||
|
if has_node("SfxDisarmSuccess"):
|
||||||
|
$SfxDisarmSuccess.play()
|
||||||
|
|
||||||
|
# Remove detected effect (blue glow) since trap is no longer dangerous
|
||||||
|
_remove_detected_effect()
|
||||||
|
|
||||||
# Hide disarm text
|
# Hide disarm text
|
||||||
_hide_disarm_text(null)
|
_hide_disarm_text(null)
|
||||||
|
|
||||||
@@ -318,6 +336,7 @@ func _sync_trap_disarmed() -> void:
|
|||||||
if not is_instance_valid(self) or not is_inside_tree():
|
if not is_instance_valid(self) or not is_inside_tree():
|
||||||
return
|
return
|
||||||
is_disarmed = true
|
is_disarmed = true
|
||||||
|
_remove_detected_effect()
|
||||||
if sprite:
|
if sprite:
|
||||||
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
|
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
|
||||||
if activation_area:
|
if activation_area:
|
||||||
@@ -424,10 +443,16 @@ func _deal_trap_damage(player: Node) -> void:
|
|||||||
_show_floating_text("AVOIDED", Color.GREEN)
|
_show_floating_text("AVOIDED", Color.GREEN)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply trap damage (affected by player's defense)
|
# Apply trap damage (affected by player's defense) - must run on player's authority so take_damage and death run correctly
|
||||||
var final_damage = player.character_stats.calculate_damage(trap_damage, false, false)
|
var final_damage = player.character_stats.calculate_damage(trap_damage, false, false)
|
||||||
|
|
||||||
if player.has_method("rpc_take_damage"):
|
if player.has_method("rpc_take_damage"):
|
||||||
|
var pid = player.get_multiplayer_authority()
|
||||||
|
if multiplayer.get_unique_id() == pid:
|
||||||
|
player.take_damage(trap_damage, global_position)
|
||||||
|
elif multiplayer.has_multiplayer_peer() and pid != 0:
|
||||||
|
player.rpc_take_damage.rpc_id(pid, trap_damage, global_position)
|
||||||
|
else:
|
||||||
player.rpc_take_damage(trap_damage, global_position)
|
player.rpc_take_damage(trap_damage, global_position)
|
||||||
|
|
||||||
print(player.name, " took ", final_damage, " trap damage")
|
print(player.name, " took ", final_damage, " trap damage")
|
||||||
|
|||||||