diff --git a/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav new file mode 100644 index 0000000..0e59e61 Binary files /dev/null and b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav differ diff --git a/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav.import b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav.import new file mode 100644 index 0000000..8d1e533 --- /dev/null +++ b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav.import @@ -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 diff --git a/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav new file mode 100644 index 0000000..f8bd2d7 Binary files /dev/null and b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav differ diff --git a/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav.import b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav.import new file mode 100644 index 0000000..d8abc90 --- /dev/null +++ b/src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/Dungeon Teleporting.wav b/src/assets/audio/sfx/z3/Dungeon Teleporting.wav new file mode 100644 index 0000000..36bbd35 Binary files /dev/null and b/src/assets/audio/sfx/z3/Dungeon Teleporting.wav differ diff --git a/src/assets/audio/sfx/z3/Dungeon Teleporting.wav.import b/src/assets/audio/sfx/z3/Dungeon Teleporting.wav.import new file mode 100644 index 0000000..e8c8592 --- /dev/null +++ b/src/assets/audio/sfx/z3/Dungeon Teleporting.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/Hidden Treasure.wav b/src/assets/audio/sfx/z3/Hidden Treasure.wav new file mode 100644 index 0000000..c0353bf Binary files /dev/null and b/src/assets/audio/sfx/z3/Hidden Treasure.wav differ diff --git a/src/assets/audio/sfx/z3/Hidden Treasure.wav.import b/src/assets/audio/sfx/z3/Hidden Treasure.wav.import new file mode 100644 index 0000000..abcb105 --- /dev/null +++ b/src/assets/audio/sfx/z3/Hidden Treasure.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav b/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav new file mode 100644 index 0000000..e439537 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav.import b/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav.import new file mode 100644 index 0000000..1a8e82c --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav b/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav new file mode 100644 index 0000000..67a6975 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav.import b/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav.import new file mode 100644 index 0000000..9aa0aeb --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Enemy_Fall.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav b/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav new file mode 100644 index 0000000..1062b3f Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav.import b/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav.import new file mode 100644 index 0000000..8d8cf94 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Enemy_Jump.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav b/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav new file mode 100644 index 0000000..00d9e0e Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav.import b/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav.import new file mode 100644 index 0000000..6d6863b --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Get_Item2.wav b/src/assets/audio/sfx/z3/LA_Get_Item2.wav new file mode 100644 index 0000000..61dbb98 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Get_Item2.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Get_Item2.wav.import b/src/assets/audio/sfx/z3/LA_Get_Item2.wav.import new file mode 100644 index 0000000..16f3311 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Get_Item2.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav b/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav new file mode 100644 index 0000000..2a06e94 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav.import b/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav.import new file mode 100644 index 0000000..25ea68c --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Ground_Crumble.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Link_Fall.wav b/src/assets/audio/sfx/z3/LA_Link_Fall.wav new file mode 100644 index 0000000..22c23f8 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Link_Fall.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Link_Fall.wav.import b/src/assets/audio/sfx/z3/LA_Link_Fall.wav.import new file mode 100644 index 0000000..2ebe48c --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Link_Fall.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav b/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav new file mode 100644 index 0000000..e4a1754 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav differ diff --git a/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav.import b/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav.import new file mode 100644 index 0000000..7aa8215 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Charge.wav b/src/assets/audio/sfx/z3/LA_Sword_Charge.wav new file mode 100644 index 0000000..e92e458 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Charge.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Charge.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Charge.wav.import new file mode 100644 index 0000000..59cfc54 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Charge.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav b/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav new file mode 100644 index 0000000..4d45643 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav.import new file mode 100644 index 0000000..e1aa4db --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Slash1.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav b/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav new file mode 100644 index 0000000..be8ced3 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav.import new file mode 100644 index 0000000..41f5f07 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Slash2.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav b/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav new file mode 100644 index 0000000..6f8b560 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav.import new file mode 100644 index 0000000..15f126d --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Slash3.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav b/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav new file mode 100644 index 0000000..cf16080 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav.import new file mode 100644 index 0000000..54d8686 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Slash4.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Spin.wav b/src/assets/audio/sfx/z3/LA_Sword_Spin.wav new file mode 100644 index 0000000..962a5e7 Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Spin.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Spin.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Spin.wav.import new file mode 100644 index 0000000..dfe75b6 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Spin.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Tap.wav b/src/assets/audio/sfx/z3/LA_Sword_Tap.wav new file mode 100644 index 0000000..f330b6b Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Tap.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Tap.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Tap.wav.import new file mode 100644 index 0000000..184f286 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Tap.wav.import @@ -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 diff --git a/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav b/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav new file mode 100644 index 0000000..81db9fb Binary files /dev/null and b/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav differ diff --git a/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav.import b/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav.import new file mode 100644 index 0000000..efa6629 --- /dev/null +++ b/src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav.import @@ -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 diff --git a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesBrown.png b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesBrown.png index 786cefe..469a21a 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesBrown.png and b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesBrown.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesLightBrown.png b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesLightBrown.png index f77fe6b..020c2e6 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesLightBrown.png and b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesLightBrown.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesMaple.png b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesMaple.png index c3596e3..69d6422 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesMaple.png and b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/GlovesMaple.png differ diff --git a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/IronGloves.png b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/IronGloves.png index 5fa8078..c62b8f6 100644 Binary files a/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/IronGloves.png and b/src/assets/gfx/Puny-Characters/Layer 3 - Gloves/IronGloves.png differ diff --git a/src/scenes/attack_web_shot.tscn b/src/scenes/attack_web_shot.tscn new file mode 100644 index 0000000..864e17d --- /dev/null +++ b/src/scenes/attack_web_shot.tscn @@ -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" diff --git a/src/scenes/boss_room_test.tscn b/src/scenes/boss_room_test.tscn index 3b9a957..4b31760 100644 --- a/src/scenes/boss_room_test.tscn +++ b/src/scenes/boss_room_test.tscn @@ -4,19 +4,8 @@ [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://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"] - -[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) +[ext_resource type="PackedScene" uid="uid://kmlhn1iaceg8" path="res://scenes/boss_spider_bat.tscn" id="6_w00nx"] [node name="BossRoomTest" type="Node2D" unique_id=1788886249] script = ExtResource("1_qh2jl") @@ -51,7 +40,7 @@ position = Vector2(323, 352) rotation = 3.1415927 [node name="TorchWall4" parent="Environment" unique_id=600381662 instance=ExtResource("2_qh2jl")] -position = Vector2(421, 351) +position = Vector2(396, 351) rotation = 3.1415927 [node name="Entities" type="Node2D" parent="." unique_id=1341597062] @@ -126,11 +115,5 @@ light_mask = 1048575 visibility_layer = 1048575 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) -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") diff --git a/src/scenes/boss_spider_bat.tscn b/src/scenes/boss_spider_bat.tscn new file mode 100644 index 0000000..f8cae41 --- /dev/null +++ b/src/scenes/boss_spider_bat.tscn @@ -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"] diff --git a/src/scenes/detected_effect.tscn b/src/scenes/detected_effect.tscn new file mode 100644 index 0000000..1599e60 --- /dev/null +++ b/src/scenes/detected_effect.tscn @@ -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 diff --git a/src/scenes/enemy_hand.tscn b/src/scenes/enemy_hand.tscn index 25fe888..c7cf8ed 100644 --- a/src/scenes/enemy_hand.tscn +++ b/src/scenes/enemy_hand.tscn @@ -87,6 +87,9 @@ radius = 5.0 [sub_resource type="CircleShape2D" id="CircleShape2D_ptw5w"] radius = 58.30952 +[sub_resource type="CircleShape2D" id="CircleShape2D_detect"] +radius = 50.0 + [node name="EnemyHand" type="CharacterBody2D" unique_id=512887809] collision_mask = 64 script = ExtResource("1_hqcsv") @@ -103,6 +106,13 @@ libraries/ = SubResource("AnimationLibrary_ptw5w") [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=448410993] 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] collision_layer = 0 @@ -122,6 +132,7 @@ collision_layer = 0 shape = SubResource("CircleShape2D_ptw5w") 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="GrabPlayerArea" to="." method="_on_grab_player_area_body_entered"] [connection signal="body_entered" from="PlayerInterestArea" to="." method="_on_player_interest_area_body_entered"] diff --git a/src/scenes/enemy_humanoid.tscn b/src/scenes/enemy_humanoid.tscn index df92145..9c06010 100644 --- a/src/scenes/enemy_humanoid.tscn +++ b/src/scenes/enemy_humanoid.tscn @@ -340,6 +340,7 @@ vframes = 8 [node name="Sprite2DShield" type="Sprite2D" parent="." unique_id=470468744] visible = false material = SubResource("ShaderMaterial_shield") +position = Vector2(0, -4) texture = ExtResource("14_shield") hframes = 35 vframes = 8 @@ -347,6 +348,7 @@ vframes = 8 [node name="Sprite2DShieldHolding" type="Sprite2D" parent="." unique_id=1318098286] visible = false material = SubResource("ShaderMaterial_shield") +position = Vector2(0, -4) texture = ExtResource("15_shieldh") hframes = 35 vframes = 8 diff --git a/src/scenes/enemy_slime.tscn b/src/scenes/enemy_slime.tscn index c38085e..b5d0ed9 100644 --- a/src/scenes/enemy_slime.tscn +++ b/src/scenes/enemy_slime.tscn @@ -2,6 +2,7 @@ [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="AudioStream" uid="uid://csrrj2vrkbsqj" path="res://assets/audio/sfx/z3/LA_Enemy_Jump.wav" id="3_y5ckx"] [sub_resource type="CircleShape2D" id="CircleShape2D_slime"] radius = 6.0 @@ -26,3 +27,8 @@ hframes = 15 [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=521937817] shape = SubResource("CircleShape2D_slime") + +[node name="SfxJump" type="AudioStreamPlayer2D" parent="." unique_id=1770035153] +stream = ExtResource("3_y5ckx") +attenuation = 3.363586 +bus = &"Sfx" diff --git a/src/scenes/enemy_spider.tscn b/src/scenes/enemy_spider.tscn new file mode 100644 index 0000000..bc41856 --- /dev/null +++ b/src/scenes/enemy_spider.tscn @@ -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") diff --git a/src/scenes/ingame_hud.tscn b/src/scenes/ingame_hud.tscn index 883e5ff..addefdb 100644 --- a/src/scenes/ingame_hud.tscn +++ b/src/scenes/ingame_hud.tscn @@ -185,6 +185,44 @@ horizontal_alignment = 1 layout_mode = 2 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] anchors_preset = 5 anchor_left = 0.5 diff --git a/src/scenes/interactable_object.tscn b/src/scenes/interactable_object.tscn index 5078075..3714b22 100644 --- a/src/scenes/interactable_object.tscn +++ b/src/scenes/interactable_object.tscn @@ -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://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="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"] radius = 4.0 height = 12.0 +[sub_resource type="CircleShape2D" id="CircleShape2D_detect"] +radius = 99.0 + [sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_1u1k0"] playback_mode = 1 streams_count = 7 @@ -181,6 +186,13 @@ position = Vector2(0, 2) rotation = -1.5707964 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] stream = SubResource("AudioStreamRandomizer_1u1k0") max_distance = 1187.0 @@ -206,6 +218,18 @@ bus = &"Sfx" [node name="SfxOpenChest" type="AudioStreamPlayer2D" parent="." unique_id=743332693] 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] stream = SubResource("AudioStreamRandomizer_2p257") volume_db = -2.611 @@ -234,3 +258,4 @@ process_material = SubResource("ParticleProcessMaterial_ejwle") wait_time = 0.07 [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"] diff --git a/src/scenes/loot.tscn b/src/scenes/loot.tscn index d10aba8..7a27c7e 100644 --- a/src/scenes/loot.tscn +++ b/src/scenes/loot.tscn @@ -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://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" path="res://assets/audio/sfx/z3/LA_Enemy_Fall.wav" id="10_fall"] [sub_resource type="Gradient" id="Gradient_1"] 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] 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] stream = SubResource("AudioStreamRandomizer_37k03") diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index a3e2c8f..2cf6961 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -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://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://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"] colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1) @@ -474,6 +476,21 @@ tracks/0/keys = { "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"] _data = { &"RESET": SubResource("Animation_t4otl"), @@ -484,7 +501,8 @@ _data = { &"frost_ready": SubResource("Animation_frost_rdy"), &"healing_charging": SubResource("Animation_heal_ch"), &"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"] @@ -870,26 +888,34 @@ bus = &"Sfx" [node name="SfxBirdSound" type="AudioStreamPlayer2D" parent="." unique_id=1946085725] stream = ExtResource("47_mx1m4") -volume_db = -13.255 +volume_db = -9.289 attenuation = 3.2490087 panning_strength = 1.12 bus = &"Sfx" [node name="SfxLookOut" type="AudioStreamPlayer2D" parent="." unique_id=1177750193] stream = ExtResource("48_6e8lb") -volume_db = 0.881 +volume_db = 8.813 max_distance = 1138.0 -attenuation = 7.999997 +attenuation = 5.856343 panning_strength = 1.04 -bus = &"Sfx" [node name="SfxAhaa" type="AudioStreamPlayer2D" parent="." unique_id=1556952538] stream = SubResource("AudioStreamRandomizer_lxlsd") +volume_db = 8.053 max_distance = 1496.0 attenuation = 6.062864 panning_strength = 1.13 -bus = &"Sfx" [node name="SfxDeny2" type="AudioStreamPlayer2D" parent="." unique_id=1127340261] stream = ExtResource("45_g5jhy") 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" diff --git a/src/scenes/trap.tscn b/src/scenes/trap.tscn index f7d4a04..e55699a 100644 --- a/src/scenes/trap.tscn +++ b/src/scenes/trap.tscn @@ -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="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://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"] length = 0.001 @@ -84,6 +89,14 @@ radius = 17.117243 [sub_resource type="CircleShape2D" id="CircleShape2D_62q8x"] 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] script = ExtResource("1_62q8x") @@ -128,6 +141,17 @@ attenuation = 1.9318731 bus = &"Sfx" [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" [connection signal="body_shape_entered" from="ActivationArea" to="." method="_on_activation_area_body_shape_entered"] diff --git a/src/scripts/attack_axe_swing.gd b/src/scripts/attack_axe_swing.gd index 9cf21dc..ac19a80 100644 --- a/src/scripts/attack_axe_swing.gd +++ b/src/scripts/attack_axe_swing.gd @@ -20,6 +20,7 @@ const DIR_ANIMATIONS: Array = [ var elapsed_time: float = 0.0 var player_owner: Node = null var hit_targets: Dictionary = {} +var _cut_web_rpc_sent: bool = false var damage_effect_axe_scene: PackedScene = preload("res://scenes/damage_effect_axe.tscn") @@ -31,6 +32,9 @@ func _ready() -> void: if hit_area: if not hit_area.body_entered.is_connected(_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"): $AttackSwosh.play() @@ -60,9 +64,20 @@ func _process(delta: float) -> void: elapsed_time += delta if player_owner and is_instance_valid(player_owner): 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: 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: if body == player_owner: 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) else: 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: var eid = body.get_multiplayer_authority() if eid != 0: diff --git a/src/scripts/attack_punch.gd b/src/scripts/attack_punch.gd index 64c0a76..19356ce 100644 --- a/src/scripts/attack_punch.gd +++ b/src/scripts/attack_punch.gd @@ -14,6 +14,7 @@ var punch_direction: Vector2 = Vector2.RIGHT var player_owner: Node = null var hit_targets: Dictionary = {} 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") @@ -23,8 +24,12 @@ var damage_effect_punch_scene: PackedScene = preload("res://scenes/damage_effect func _ready() -> void: if has_node("SfxSwosh"): $SfxSwosh.play() - if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): - hit_area.body_entered.connect(_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) + 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: sprite.frame = PUNCH_FRAMES[0] @@ -46,10 +51,21 @@ func _process(delta: float) -> void: if sprite and PUNCH_FRAMES.size() > 0: var frame_idx = min(int(elapsed / FRAME_DURATION), PUNCH_FRAMES.size() - 1) 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: hit_area.set_deferred("monitoring", false) 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: if body == player_owner: 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) else: 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: var enemy_peer_id = body.get_multiplayer_authority() if enemy_peer_id != 0: @@ -92,7 +111,10 @@ func _on_body_entered(body: Node2D) -> void: return # Interactables with health (boxes, etc.) - small damage + # Hidden chests must not be hit until detected (no damage, no effect) 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"): $SfxImpact.play() body.health -= damage diff --git a/src/scripts/attack_web_shot.gd b/src/scripts/attack_web_shot.gd new file mode 100644 index 0000000..78d0554 --- /dev/null +++ b/src/scripts/attack_web_shot.gd @@ -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() diff --git a/src/scripts/attack_web_shot.gd.uid b/src/scripts/attack_web_shot.gd.uid new file mode 100644 index 0000000..a226be9 --- /dev/null +++ b/src/scripts/attack_web_shot.gd.uid @@ -0,0 +1 @@ +uid://bibyqdhticm5i diff --git a/src/scripts/boss_spider_bat.gd b/src/scripts/boss_spider_bat.gd new file mode 100644 index 0000000..b7a853c --- /dev/null +++ b/src/scripts/boss_spider_bat.gd @@ -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() diff --git a/src/scripts/boss_spider_bat.gd.uid b/src/scripts/boss_spider_bat.gd.uid new file mode 100644 index 0000000..0eb3308 --- /dev/null +++ b/src/scripts/boss_spider_bat.gd.uid @@ -0,0 +1 @@ +uid://bgdwn43m2yrtl diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 5951b91..7e84971 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -4,7 +4,7 @@ extends Resource signal health_changed(new_health: float, max_health: float) signal mana_changed(new_mana: float, max_mana: float) signal level_changed(new_level: int) -signal level_up_stats(stats_increased: Array) # Emitted when leveling up, contains array of stat names that were increased +signal level_up_stats(stats_increased: Array) # Emitted when leveling up, contains array of stat names that were increased signal xp_changed(new_xp: float, xp_to_next: float) signal no_health @@ -13,7 +13,7 @@ signal character_changed(char: CharacterStats) signal signal_drop_item(item: Item) var character_type: String = "enemy" -var race: String = "Human" # "Dwarf", "Elf", or "Human" +var race: String = "Human" # "Dwarf", "Elf", or "Human" @export var level: int = 1 @export var character_name: String = "" @export var xp: float = 0 @@ -21,17 +21,17 @@ var race: String = "Human" # "Dwarf", "Elf", or "Human" @export var mp: float = 20.0 # default skin is human1 -var skin:String = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png" +var skin: String = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human1.png" # default no values for these: -var facial_hair:String = "" -var facial_hair_color:Color = Color.WHITE -var hairstyle:String = "" -var hair_color:Color = Color.WHITE -var eyes:String = "" -var eye_color:Color = Color.WHITE -var eye_lashes:String = "" -var eyelash_color:Color = Color.WHITE -var add_on:String = "" +var facial_hair: String = "" +var facial_hair_color: Color = Color.WHITE +var hairstyle: String = "" +var hair_color: Color = Color.WHITE +var eyes: String = "" +var eye_color: Color = Color.WHITE +var eye_lashes: String = "" +var eyelash_color: Color = Color.WHITE +var add_on: String = "" var bonusmaxhp: float = 0.0 var bonusmaxmp: float = 0.0 @@ -47,7 +47,7 @@ var pending_stat_points: int = 0 const LEVEL_UP_STAT_NAMES: Array = ["str", "dex", "int", "end", "wis", "lck", "per"] #calculated values -var stats:Array = [ +var stats: Array = [ 5, 4, 5, @@ -62,7 +62,7 @@ var stats:Array = [ var inventory: Array = [] # mainhand, offhand, headgear, body, feet, accessory (6 total) -var equipment:Dictionary = { +var equipment: Dictionary = { "mainhand": null, "offhand": null, "headgear": null, @@ -79,7 +79,7 @@ var equipment:Dictionary = { "wis": 10, "cha": 10, "lck": 10, - "per": 10 # Perception - affects trap detection + "per": 10 # Perception - affects trap detection } @export var def: int = 0 @@ -137,7 +137,7 @@ func getCalculatedStats(): pass -func get_pass(iStr:String): +func get_pass(iStr: String): var cnt = 0 if equipment["mainhand"] != null: for key in equipment["mainhand"].modifiers.keys(): @@ -217,11 +217,17 @@ var crit_chance: float: get: 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: get: # 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) - 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: get: @@ -230,6 +236,21 @@ var hit_chance: float: # Formula: 95% base + (DEX * 0.3%) 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: get: # 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: hp = 0.0 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: mp = clamp(mp + amount, 0, 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: # Apply defense reduction - more like D&D where defense provides minimal protection @@ -307,7 +330,7 @@ func calculate_damage(base_damage: float, is_magical: bool = false, is_critical: if not is_magical: # Physical damage: defense provides percentage reduction (like D&D armor) # Defense value is converted to percentage: 1 DEF = 2% reduction, max 50% reduction - var defense_percentage = min(0.5, defense * 0.02) # Max 50% reduction + var defense_percentage = min(0.5, defense * 0.02) # Max 50% reduction var effective_defense = defense_percentage if is_critical: # Critical hits pierce 80% of DEF (only 20% applies) @@ -327,7 +350,7 @@ func take_damage(amount: float, is_magical: bool = false) -> float: modify_health(-actual_damage) # Check if dead (use epsilon to handle floating point precision) if hp <= 0.001: - hp = 0.0 # Ensure exactly 0 + hp = 0.0 # Ensure exactly 0 no_health.emit() # Emit when health reaches 0 character_changed.emit(self) return actual_damage @@ -346,7 +369,7 @@ func restore_mana(amount: float) -> void: func saveInventory() -> Array: var inventorySave = [] - for it:Item in inventory: + for it: Item in inventory: inventorySave.push_back(it.save()) return inventorySave @@ -365,7 +388,7 @@ func loadInventory(iArr: Array): inventory.clear() # remove previous content for iDic in iArr: if iDic != null: - inventory.push_back( Item.new(iDic) ) + inventory.push_back(Item.new(iDic)) pass func loadEquipment(iDic: Dictionary): @@ -373,7 +396,7 @@ func loadEquipment(iDic: Dictionary): equipment["offhand"] = Item.new(iDic.get("offhand")) if iDic.has("offhand") and iDic.get("offhand") != null else null equipment["headgear"] = Item.new(iDic.get("headgear")) if iDic.has("headgear") and iDic.get("headgear") != null else null equipment["armour"] = Item.new(iDic.get("armour")) if iDic.has("armour") and iDic.get("armour") != null else null - equipment["boots"] = Item.new(iDic.get("boots")) if iDic.has("boots") and iDic.get("boots") != null else null + equipment["boots"] = Item.new(iDic.get("boots")) if iDic.has("boots") and iDic.get("boots") != null else null equipment["accessory"] = Item.new(iDic.get("accessory")) if iDic.has("accessory") and iDic.get("accessory") != null else null pass @@ -383,7 +406,7 @@ func save() -> Dictionary: "character_type": character_type, "character_name": character_name, - "race": race, # Save race + "race": race, # Save race "baseStats": baseStats, "hp": hp, @@ -408,8 +431,8 @@ func save() -> Dictionary: "facial_hair_color": facial_hair_color.to_html(true), "hair_color": hair_color.to_html(true), - "eye_color": eye_color.to_html(true), # Save eye color - "eyelash_color": eyelash_color.to_html(true), # Save eyelash color + "eye_color": eye_color.to_html(true), # Save eye color + "eyelash_color": eyelash_color.to_html(true), # Save eyelash color "inventory": saveInventory(), "equipment": saveEquipment() @@ -471,7 +494,7 @@ func load(iDic: Dictionary) -> void: if iDic.has("eyes"): eyes = iDic.get("eyes") if iDic.has("eye_lashes"): - eye_lashes = iDic.get("eye_lashes") + eye_lashes = iDic.get("eye_lashes") if iDic.has("add_on"): add_on = iDic.get("add_on") if iDic.has("facial_hair_color"): @@ -504,29 +527,29 @@ func calculateStats(): pass' -func add_coin(iAmount:int): +func add_coin(iAmount: int): coin += iAmount emit_signal("character_changed", self) pass -func drop_item(iItem:Item): +func drop_item(iItem: Item): var index = 0 for item in inventory: if item == iItem: break - index+=1 + index += 1 inventory.remove_at(index) emit_signal("signal_drop_item", iItem) emit_signal("character_changed", self) pass -func drop_equipment(iItem:Item): +func drop_equipment(iItem: Item): unequip_item(iItem, false) # directly remove the item from the inventory drop_item(iItem) pass -func add_item(iItem:Item): +func add_item(iItem: Item): # Try to stack with existing items if possible if iItem.can_have_multiple_of: for existing_item in inventory: @@ -570,7 +593,7 @@ func add_item(iItem:Item): emit_signal("character_changed", self) pass -func unequip_item(iItem:Item, updateChar:bool = true): +func unequip_item(iItem: Item, updateChar: bool = true): if iItem.equipment_type == Item.EquipmentType.NONE: return @@ -612,7 +635,7 @@ func forceUpdate(): emit_signal("character_changed", self) pass -func equip_item(iItem:Item, insert_index: int = -1): +func equip_item(iItem: Item, insert_index: int = -1): # insert_index: if >= 0, place old item at this index instead of at the end if iItem.equipment_type == Item.EquipmentType.NONE: return @@ -692,14 +715,14 @@ func equip_item(iItem:Item, insert_index: int = -1): emit_signal("character_changed", self) pass -func setSkin(iValue:int): +func setSkin(iValue: int): if iValue < 0 or iValue > 6: return - skin = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(iValue+1) + ".png" + skin = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(iValue + 1) + ".png" emit_signal("character_changed", self) pass -func setFacialHair(iType:int): +func setFacialHair(iType: int): if iType < 0 or iType > 3: return @@ -722,7 +745,7 @@ func setFacialHair(iType:int): pass -func setHair(iType:int): +func setHair(iType: int): if iType < 0 or iType > 12: return if iType == 0: @@ -731,7 +754,7 @@ func setHair(iType:int): return hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/FHairstyle" + str(iType) if iType >= 5: # male hairstyles - hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(iType-4) + hairstyle = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(iType - 4) hairstyle += "White.png" emit_signal("character_changed", self) pass @@ -837,7 +860,7 @@ func setFacialHairColor(iColor: Color): facial_hair_color = iColor emit_signal("character_changed", self) pass -func setHairColor(iColor:Color): +func setHairColor(iColor: Color): hair_color = iColor emit_signal("character_changed", self) pass diff --git a/src/scripts/detected_effect.gd b/src/scripts/detected_effect.gd new file mode 100644 index 0000000..40ac9dc --- /dev/null +++ b/src/scripts/detected_effect.gd @@ -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 diff --git a/src/scripts/detected_effect.gd.uid b/src/scripts/detected_effect.gd.uid new file mode 100644 index 0000000..096a377 --- /dev/null +++ b/src/scripts/detected_effect.gd.uid @@ -0,0 +1 @@ +uid://b45h84vbq3jw diff --git a/src/scripts/door.gd b/src/scripts/door.gd index a0a80a2..dbd243a 100644 --- a/src/scripts/door.gd +++ b/src/scripts/door.gd @@ -6,34 +6,34 @@ extends StaticBody2D #KeyDoors should always be closed at start #StoneDoor and GateDoor CAN be opened in start, and become closed when entering it's room #Then you must press a switch in the room or maybe you need to defeat all enemies in the room -@export var is_closed: bool = true -var is_closing:bool = false -var is_opening:bool = false -var time_to_move:float = 0.2 -var move_timer:float = 0.0 -var animation_start_position: Vector2 = Vector2.ZERO # Position when animation started +@export var is_closed: bool = true +var is_closing: bool = false +var is_opening: bool = false +var time_to_move: float = 0.2 +var move_timer: float = 0.0 +var animation_start_position: Vector2 = Vector2.ZERO # Position when animation started -var closed_position: Vector2 = Vector2.ZERO # Position when door is closed (local) -var open_offset: Vector2 = Vector2.ZERO # Offset from closed to open position (local) +var closed_position: Vector2 = Vector2.ZERO # Position when door is closed (local) +var open_offset: Vector2 = Vector2.ZERO # Offset from closed to open position (local) # Room and puzzle state -var blocking_room: Dictionary = {} # Room this door blocks (the room you enter INTO) -var room1: Dictionary = {} # First room connected by this door (room you leave FROM) -var room2: Dictionary = {} # Second room connected by this door (room you enter INTO - the blocking_room) -var switch_room: Dictionary = {} # Room where the switch is located (before the door) -var room_trigger_area: Area2D = null # Reference to room trigger area for the blocking room -var puzzle_solved: bool = false # True when room puzzle is solved -var enemies_defeated: bool = false # True when all enemies in room are defeated -var switches_activated: bool = false # True when required switches are activated +var blocking_room: Dictionary = {} # Room this door blocks (the room you enter INTO) +var room1: Dictionary = {} # First room connected by this door (room you leave FROM) +var room2: Dictionary = {} # Second room connected by this door (room you enter INTO - the blocking_room) +var switch_room: Dictionary = {} # Room where the switch is located (before the door) +var room_trigger_area: Area2D = null # Reference to room trigger area for the blocking room +var puzzle_solved: bool = false # True when room puzzle is solved +var enemies_defeated: bool = false # True when all enemies in room are defeated +var switches_activated: bool = false # True when required switches are activated # Key door state -var key_used: bool = false # True when key has been used -var key_indicator: Sprite2D = null # Visual indicator showing key above door +var key_used: bool = false # True when key has been used +var key_indicator: Sprite2D = null # Visual indicator showing key above door # Floor switches this door is connected to -var connected_switches: Array = [] # Array of floor switch nodes -var requires_enemies: bool = false # True if door requires defeating enemies to open -var requires_switch: bool = false # True if door requires activating switches to open +var connected_switches: Array = [] # Array of floor switch nodes +var requires_enemies: bool = false # True if door requires defeating enemies to open +var requires_switch: bool = false # True if door requires activating switches to open # Smoke puff scene for StoneDoor effects var smoke_puff_scene = preload("res://scenes/smoke_puff.tscn") @@ -45,9 +45,9 @@ func _ready() -> void: # Rotate door first based on direction (original order) if direction == "Left": - self.rotate(-PI/2) + self.rotate(-PI / 2) elif direction == "Right": - self.rotate(PI/2) + self.rotate(PI / 2) elif direction == "Down": self.rotate(PI) @@ -68,7 +68,7 @@ func _ready() -> void: # So open_offset is positive Y (door moves down when closing, so open is up) # Actually wait - if closed is 16px down from open, then open is 16px up from closed # So open_offset should be negative Y (open position is above closed position) - open_offset = Vector2(0, -open_amount) # Open is 16px UP from closed + open_offset = Vector2(0, -open_amount) # Open is 16px UP from closed elif direction == "Down": # Door on bottom wall: # For StoneDoor/GateDoor: open is at (col 1, row 1), closed is at (col 1, row 0) @@ -76,19 +76,19 @@ func _ready() -> void: # For KeyDoor: closed is at (col 1, row 0), open is at (col 1, row 1) # So open is 16px DOWN from closed, open_offset = (0, 16) # NOTE: This is recalculated in _ready_after_setup() based on door type - open_offset = Vector2(0, -open_amount) # For StoneDoor/GateDoor: open is 16px DOWN from closed + open_offset = Vector2(0, -open_amount) # For StoneDoor/GateDoor: open is 16px DOWN from closed elif direction == "Left": # Door on left wall: closed state is 16px RIGHT from open state # So open_offset is positive X (door moves right when closing, so open is left) # Actually wait - if closed is 16px right from open, then open is 16px left from closed # So open_offset should be negative X (open position is left of closed position) - open_offset = Vector2(-open_amount, 0) # Open is 16px LEFT from closed + open_offset = Vector2(-open_amount, 0) # Open is 16px LEFT from closed elif direction == "Right": # Door on right wall: closed state is 16px LEFT from open state # So open_offset is negative X (door moves left when closing, so open is right) # Actually wait - if closed is 16px left from open, then open is 16px right from closed # So open_offset should be positive X (open position is right of closed position) - open_offset = Vector2(open_amount, 0) # Open is 16px RIGHT from closed + open_offset = Vector2(open_amount, 0) # Open is 16px RIGHT from closed # Note: closed_position will be set in _ready_after_setup after door is positioned # For now, just initialize it @@ -169,9 +169,9 @@ func _process(delta: float) -> void: var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else closed_position var target_pos = closed_position + open_offset position = start_pos.lerp(target_pos, progress) - global_position = position # Also update global position during animation + global_position = position # Also update global position during animation # Debug: log for KeyDoors to verify movement - if type == "KeyDoor" and move_timer < 0.1: # Only log once at start of animation + if type == "KeyDoor" and move_timer < 0.1: # Only log once at start of animation LogManager.log("Door: KeyDoor opening animation - start: " + str(start_pos) + ", target: " + str(target_pos) + ", offset: " + str(open_offset) + ", direction: " + str(direction), LogManager.CATEGORY_DOOR) # For KeyDoors: disable collision as soon as opening starts (allow passage immediately) @@ -198,7 +198,7 @@ func _process(delta: float) -> void: # If animation_start_position wasn't set, calculate open position from closed_position + open_offset var start_pos = animation_start_position if animation_start_position != Vector2.ZERO else (closed_position + open_offset) position = start_pos.lerp(closed_position, progress) - global_position = position # Also update global position during animation + global_position = position # Also update global position during animation # Update collision for StoneDoor/GateDoor only if type == "StoneDoor" or type == "GateDoor": @@ -220,7 +220,7 @@ func _process(delta: float) -> void: # Move door to open position (away from closed position) var open_position = closed_position + open_offset position = open_position - global_position = open_position # Also set global position + global_position = open_position # Also set global position # When moved from closed position (open), collision should be DISABLED set_collision_layer_value(7, false) var key_used_str = " (key_used=" + str(key_used) + ")" if type == "KeyDoor" else "" @@ -239,12 +239,12 @@ func _process(delta: float) -> void: is_opening = false is_closing = false move_timer = 0.0 - animation_start_position = Vector2.ZERO # Reset animation start position + animation_start_position = Vector2.ZERO # Reset animation start position else: # Closing animation complete is_closed = true position = closed_position - global_position = closed_position # Also set global position + global_position = closed_position # Also set global position # When at closed position, collision should be ENABLED set_collision_layer_value(7, true) LogManager.log("Door: Closing animation complete - moved to closed position: " + str(closed_position) + " - collision ENABLED", LogManager.CATEGORY_DOOR) @@ -257,7 +257,7 @@ func _process(delta: float) -> void: is_opening = false is_closing = false move_timer = 0.0 - animation_start_position = Vector2.ZERO # Reset animation start position + animation_start_position = Vector2.ZERO # Reset animation start position # Now that door has finished closing, check if puzzle is solved (to open it immediately if already solved) if type == "StoneDoor" or type == "GateDoor": _check_puzzle_state() @@ -312,11 +312,11 @@ func _update_collision_based_on_position(): # CRITICAL: This function ONLY updates collision, it does NOT change position or is_closed flag # Position and is_closed should only be changed by explicit _open()/_close() calls or animation if type == "KeyDoor": - return # Don't update KeyDoors - they handle their own state + return # Don't update KeyDoors - they handle their own state # Only update collision, don't change position or is_closed flag var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 - var distance_threshold = 1.0 # Consider "at closed position" if within 1 pixel + var distance_threshold = 1.0 # Consider "at closed position" if within 1 pixel if distance_to_closed <= distance_threshold: # Door is at closed position - collision should be ENABLED @@ -330,7 +330,7 @@ func _update_collision_based_on_position(): func _open(): # Only open on server/authority in multiplayer, then sync to clients if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): - return # Clients wait for RPC + return # Clients wait for RPC $TeleporterIntoClosedRoom.is_enabled = false # CRITICAL: For KeyDoors, ensure they start from closed position before opening @@ -343,7 +343,7 @@ func _open(): position = closed_position global_position = closed_position is_closed = true - set_collision_layer_value(7, true) # Collision enabled at closed position + set_collision_layer_value(7, true) # Collision enabled at closed position LogManager.log("Door: KeyDoor _open() called - reset to closed position " + str(closed_position) + " before opening", LogManager.CATEGORY_DOOR) else: LogManager.log_error("Door: KeyDoor _open() called but closed_position is zero!", LogManager.CATEGORY_DOOR) @@ -363,7 +363,7 @@ func _open(): global_position = open_pos is_closed = false set_collision_layer_value(7, false) - return # Don't start animation + return # Don't start animation # Door is closed - ensure it's at closed position before opening if closed_position != Vector2.ZERO: @@ -409,7 +409,7 @@ func _open(): func _close(): # Only close on server/authority in multiplayer, then sync to clients if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): - return # Clients wait for RPC + return # Clients wait for RPC # CRITICAL: KeyDoors should NEVER be closed (they only open with a key and stay open) if type == "KeyDoor": @@ -424,23 +424,23 @@ func _close(): # Check both flag and actual position to determine door state var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 - var is_actually_at_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position + var is_actually_at_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position LogManager.log("Door: _close() called - is_closed: " + str(is_closed) + ", is_actually_at_closed: " + str(is_actually_at_closed) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) # If door is already at closed position (both visually and by flag), don't do anything if is_closed and is_actually_at_closed and not is_opening and not is_closing: LogManager.log("Door: Already closed (both flag and position match), not closing again", LogManager.CATEGORY_DOOR) - return # Already closed, don't do anything + return # Already closed, don't do anything # CRITICAL: If door is at closed position but flag says open, just fix the state - don't animate if is_actually_at_closed and not is_closed: # Door is visually at closed position but flag says open - fix state only LogManager.log("Door: Door is at closed position but flag says open! Fixing state only (no animation)", LogManager.CATEGORY_DOOR) - position = closed_position # Ensure exact position + position = closed_position # Ensure exact position is_closed = true set_collision_layer_value(7, true) - return # Don't start animation + return # Don't start animation # Door is actually open (position is away from closed position) - start closing animation # CRITICAL: Store starting position BEFORE starting animation @@ -493,7 +493,7 @@ func _ready_after_setup(): # Called after door is fully set up with room references and positioned # NEW LOGIC: Door is positioned at OPEN tile position by game_world # The position set by game_world is the OPEN position (initial state for blocking doors) - var open_position = position # Current position is the OPEN position (from tile coordinates) + var open_position = position # Current position is the OPEN position (from tile coordinates) LogManager.log("Door: _ready_after_setup() called - type: " + str(type) + ", direction: " + str(direction) + ", is_closed: " + str(is_closed) + ", open_position: " + str(open_position), LogManager.CATEGORY_DOOR) @@ -508,7 +508,7 @@ func _ready_after_setup(): var closed_offset = Vector2.ZERO match direction: "Up": - closed_offset = Vector2(0, 16) # Closed is 16px DOWN from open + closed_offset = Vector2(0, 16) # Closed is 16px DOWN from open "Down": # CRITICAL: For StoneDoor/GateDoor, they start OPEN at (col 1, row 1) and close to (col 1, row 0) # So closed is 16px UP from open (negative Y) @@ -517,20 +517,20 @@ func _ready_after_setup(): if type == "KeyDoor": # KeyDoor: closed is at row 0, open is at row 1 (16px down) # But we calculate from open_position, so this won't be used for KeyDoor - closed_offset = Vector2(0, -16) # Won't be used - KeyDoor uses different logic + closed_offset = Vector2(0, -16) # Won't be used - KeyDoor uses different logic else: # StoneDoor/GateDoor: open is at row 1, closed is at row 0 (16px up) - closed_offset = Vector2(0, -16) # Closed is 16px UP from open + closed_offset = Vector2(0, -16) # Closed is 16px UP from open "Left": - closed_offset = Vector2(16, 0) # Closed is 16px RIGHT from open + closed_offset = Vector2(16, 0) # Closed is 16px RIGHT from open "Right": - closed_offset = Vector2(-16, 0) # Closed is 16px LEFT from open + closed_offset = Vector2(-16, 0) # Closed is 16px LEFT from open closed_position = open_position + closed_offset # Update open_offset for animation logic (offset from closed to open) # This is used when opening from closed position - open_offset = -closed_offset # open_offset = (0, -16) means open is 16px up from closed + open_offset = - closed_offset # open_offset = (0, -16) means open is 16px up from closed LogManager.log("Door: Calculated positions - open: " + str(open_position) + ", closed: " + str(closed_position) + ", closed_offset: " + str(closed_offset) + ", open_offset: " + str(open_offset), LogManager.CATEGORY_DOOR) @@ -541,38 +541,38 @@ func _ready_after_setup(): if type == "KeyDoor": # For KeyDoors, the position from game_world is the CLOSED position # Calculate open position from closed position - var keydoor_closed_position = position # Current position is CLOSED (from game_world) + var keydoor_closed_position = position # Current position is CLOSED (from game_world) # Calculate open position based on direction var keydoor_open_offset = Vector2.ZERO match direction: "Up": - keydoor_open_offset = Vector2(0, -16) # Open is 16px UP from closed + keydoor_open_offset = Vector2(0, -16) # Open is 16px UP from closed "Down": - keydoor_open_offset = Vector2(0, 16) # Open is 16px DOWN from closed (row 0 to row 1) + keydoor_open_offset = Vector2(0, 16) # Open is 16px DOWN from closed (row 0 to row 1) "Left": - keydoor_open_offset = Vector2(-16, 0) # Open is 16px LEFT from closed + keydoor_open_offset = Vector2(-16, 0) # Open is 16px LEFT from closed "Right": - keydoor_open_offset = Vector2(16, 0) # Open is 16px RIGHT from closed + keydoor_open_offset = Vector2(16, 0) # Open is 16px RIGHT from closed # Set positions correctly for KeyDoor - closed_position = keydoor_closed_position # Closed is where game_world placed it - open_offset = keydoor_open_offset # Offset to move from closed to open + closed_position = keydoor_closed_position # Closed is where game_world placed it + open_offset = keydoor_open_offset # Offset to move from closed to open # KeyDoor starts CLOSED is_closed = true position = closed_position global_position = closed_position - set_collision_layer_value(7, true) # Collision enabled when closed + set_collision_layer_value(7, true) # Collision enabled when closed LogManager.log("Door: KeyDoor starting CLOSED at position " + str(position) + " (direction: " + str(direction) + "), will open to " + str(closed_position + open_offset) + " - collision ENABLED", LogManager.CATEGORY_DOOR) # Create key indicator sprite for KeyDoor _create_key_indicator() - return # Exit early for KeyDoors + return # Exit early for KeyDoors elif is_closed: # StoneDoor/GateDoor starting closed (shouldn't happen for blocking doors, but handle it) position = closed_position global_position = closed_position - is_closed = true # Ensure state matches position + is_closed = true # Ensure state matches position set_collision_layer_value(7, true) LogManager.log("Door: Starting CLOSED at position " + str(position) + " (type: " + str(type) + ", direction: " + str(direction) + ") - collision ENABLED", LogManager.CATEGORY_DOOR) else: @@ -584,26 +584,26 @@ func _ready_after_setup(): LogManager.log("Door: WARNING - Position doesn't match open_position! Forcing to open: " + str(open_position) + " (was: " + str(position) + ")", LogManager.CATEGORY_DOOR) position = open_position - global_position = position # Ensure global_position matches position - is_closed = false # CRITICAL: State MUST be false (open) when at open position - set_collision_layer_value(7, false) # CRITICAL: Collision MUST be DISABLED when open + global_position = position # Ensure global_position matches position + is_closed = false # CRITICAL: State MUST be false (open) when at open position + set_collision_layer_value(7, false) # CRITICAL: Collision MUST be DISABLED when open LogManager.log("Door: Starting OPEN at position " + str(position) + " (closed: " + str(closed_position) + ", open: " + str(open_position) + ", open_offset: " + str(open_offset) + ", type: " + str(type) + ", direction: " + str(direction) + ") - collision DISABLED, is_closed: " + str(is_closed), LogManager.CATEGORY_DOOR) # CRITICAL: Verify the door is actually at open position after setting it var actual_distance = position.distance_to(closed_position) - var expected_distance = 16.0 # Should be 16 pixels away + var expected_distance = 16.0 # Should be 16 pixels away if abs(actual_distance - expected_distance) > 2.0: LogManager.log_error("Door: ERROR - Door open/closed distance is wrong! Position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(actual_distance) + " (expected: " + str(expected_distance) + ")", LogManager.CATEGORY_DOOR) # Force it to correct open position position = open_position global_position = open_position - is_closed = false # CRITICAL: Ensure state is false when at open position + is_closed = false # CRITICAL: Ensure state is false when at open position set_collision_layer_value(7, false) LogManager.log("Door: FORCED door to open position: " + str(position) + " (distance to closed: " + str(position.distance_to(closed_position)) + ", is_closed: " + str(is_closed) + ")", LogManager.CATEGORY_DOOR) # FINAL VERIFICATION: Double-check state matches position var distance_to_closed = position.distance_to(closed_position) - var should_be_open = distance_to_closed > 8.0 # If more than 8px from closed, should be open + var should_be_open = distance_to_closed > 8.0 # If more than 8px from closed, should be open if should_be_open and is_closed: LogManager.log_error("Door: ERROR - Door is at open position but is_closed is true! Fixing state...", LogManager.CATEGORY_DOOR) is_closed = false @@ -624,7 +624,7 @@ func _ready_after_setup(): func _create_key_indicator(): # Create visual indicator for key above door if key_indicator: - return # Already created + return # Already created key_indicator = Sprite2D.new() # Load key texture from loot system @@ -633,9 +633,9 @@ func _create_key_indicator(): key_indicator.texture = key_texture key_indicator.hframes = 20 key_indicator.vframes = 14 - key_indicator.frame = (13 * 20) + 10 # Key frame from loot system - key_indicator.position = Vector2(0, -24) # Above door - key_indicator.visible = false # Hidden until key is used + key_indicator.frame = (13 * 20) + 10 # Key frame from loot system + key_indicator.position = Vector2(0, -24) # Above door + key_indicator.visible = false # Hidden until key is used add_child(key_indicator) func _on_room_entered(body): @@ -646,7 +646,7 @@ func _on_room_entered(body): # Verify this door is in the room we just entered if not room_trigger_area: - return # No trigger set, don't do anything + return # No trigger set, don't do anything var trigger_room = room_trigger_area.room if room_trigger_area.room else {} var door_room1 = room1 if room1 else {} @@ -675,7 +675,7 @@ func _on_room_entered(body): if not puzzle_solved: # Check both is_closed flag AND actual position to determine door state var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 - var is_actually_open = distance_to_closed > 5.0 # If door is more than 5 pixels away from closed_position, it's open + var is_actually_open = distance_to_closed > 5.0 # If door is more than 5 pixels away from closed_position, it's open LogManager.log("Door: _on_room_entered() - type: " + str(type) + ", is_closed: " + str(is_closed) + ", is_actually_open: " + str(is_actually_open) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) @@ -699,7 +699,7 @@ func _on_room_entered(body): _close() # Don't check puzzle state immediately - wait for door to finish closing # Puzzle state will be checked when closing animation completes (in _process) - return # Exit early, don't check puzzle state yet + return # Exit early, don't check puzzle state yet elif is_actually_open: # Door is open but animation already in progress - don't interfere LogManager.log("Door: Door is open but animation in progress, not closing", LogManager.CATEGORY_DOOR) @@ -714,7 +714,7 @@ func _on_room_entered(body): position = closed_position global_position = closed_position is_closed = true - set_collision_layer_value(7, true) # Collision ENABLED when closed + set_collision_layer_value(7, true) # Collision ENABLED when closed LogManager.log("Door: Door was already closed - ensuring state is correct, position: " + str(position) + ", closed: " + str(closed_position), LogManager.CATEGORY_DOOR) # Now that door is confirmed closed, check if puzzle is already solved # CRITICAL: Only check puzzle state if door is closed - don't check if puzzle is already solved @@ -732,7 +732,7 @@ func _on_room_exited(body): func _check_puzzle_state(): # Only check puzzle state on server/authority in multiplayer if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): - return # Clients wait for server to check and sync via RPC + return # Clients wait for server to check and sync via RPC # CRITICAL: Don't check puzzle state while door is animating (closing or opening) # This prevents race conditions where switch triggers before door finishes closing @@ -742,9 +742,9 @@ func _check_puzzle_state(): # Check door's actual state (position-based check is more reliable than flags) var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 - var is_actually_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position - var is_actually_open = distance_to_closed > 5.0 # More than 5 pixels away from closed position - var collision_enabled = get_collision_layer_value(7) # Check if collision layer 7 is enabled + var is_actually_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position + var is_actually_open = distance_to_closed > 5.0 # More than 5 pixels away from closed position + var collision_enabled = get_collision_layer_value(7) # Check if collision layer 7 is enabled # CRITICAL: If puzzle_solved is true but door is not actually open (not in open position or collision still enabled), # allow switch to trigger again to open the door @@ -804,7 +804,35 @@ func _check_puzzle_state(): switches_activated = 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: + # 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 # 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 @@ -829,7 +857,7 @@ func _are_all_enemies_defeated() -> bool: if child.is_in_group("enemy"): # CRITICAL: Only check enemies that were spawned from spawners (not pre-spawned) if not child.has_meta("spawned_from_spawner") or not child.get_meta("spawned_from_spawner"): - continue # Skip pre-spawned enemies + continue # Skip pre-spawned enemies # Check if enemy is in this room (use position-based check, more reliable) var enemy_in_room = false @@ -849,7 +877,7 @@ func _are_all_enemies_defeated() -> bool: var spawner_name = child.get_meta("spawner_name") # Spawner names are like "EnemySpawner__" if str(target_room.x) in spawner_name and str(target_room.y) in spawner_name: - enemy_in_room = true # Confirmed by spawner name + enemy_in_room = true # Confirmed by spawner name if enemy_in_room: room_spawned_enemies.append(child) @@ -874,12 +902,12 @@ func _are_all_enemies_defeated() -> bool: if not enemy_is_dead: LogManager.log("Door: Enemy " + str(enemy.name) + " is still alive - puzzle not solved yet", LogManager.CATEGORY_DOOR) - return false # Enemy is still alive, puzzle not solved + return false # Enemy is still alive, puzzle not solved # If we have enemies and all are dead, puzzle is solved if room_spawned_enemies.size() > 0: LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) - return true # All enemies found are dead + return true # All enemies found are dead # No spawned enemies found - check if spawners have actually spawned enemies before # CRITICAL: Only consider puzzle solved if spawners have spawned enemies and they're all dead @@ -913,7 +941,7 @@ func _are_all_enemies_defeated() -> bool: enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y) if not enemy_in_room: - continue # Skip enemies not in this room + continue # Skip enemies not in this room # Check if enemy is alive var enemy_is_alive = false @@ -932,7 +960,6 @@ func _are_all_enemies_defeated() -> bool: # No spawned enemies found - check if spawners have actually spawned enemies before # 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) - var spawners_in_room = [] var spawners_that_have_spawned = [] @@ -1026,7 +1053,7 @@ func _are_all_enemies_defeated() -> bool: enemy_tile_y >= room_min_y and enemy_tile_y < room_max_y) if not enemy_in_room: - continue # Skip enemies not in this room + continue # Skip enemies not in this room # Check if enemy is dead var enemy_is_dead = false @@ -1053,8 +1080,8 @@ func _are_all_enemies_defeated() -> bool: # Found dead enemies with matching spawner names - spawners definitely spawned and enemies are dead if spawners_in_room.size() == 0: for spawner_name in unique_spawner_names_that_spawned.keys(): - spawners_in_room.append(null) # Placeholder for destroyed spawner - spawners_that_have_spawned.append(null) # Count as spawned + spawners_in_room.append(null) # Placeholder for destroyed spawner + spawners_that_have_spawned.append(null) # Count as spawned LogManager.log("Door: Spawner " + str(spawner_name) + " was destroyed but spawned enemies that are now all dead - counting as spawned", LogManager.CATEGORY_DOOR) else: # Spawners exist - check if any weren't counted as spawned yet @@ -1122,14 +1149,14 @@ func _are_all_enemies_defeated() -> bool: LogManager.log("Door: Enemy is no longer valid (removed from scene) - counting as dead", LogManager.CATEGORY_DOOR) LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) - return true # All enemies are dead + return true # All enemies are dead func _spawn_smoke_puffs_on_close(): # Spawn 1-3 smoke puffs when StoneDoor finishes closing if not smoke_puff_scene: return - var puff_count = randi_range(1, 3) # Random between 1-3 puffs + var puff_count = randi_range(1, 3) # Random between 1-3 puffs for i in range(puff_count): var puff = smoke_puff_scene.instantiate() @@ -1138,7 +1165,7 @@ func _spawn_smoke_puffs_on_close(): var offset_x = randf_range(-8, 8) var offset_y = randf_range(-8, 8) puff.global_position = global_position + Vector2(offset_x, offset_y) - puff.z_index = 10 # High z-index to ensure visibility + puff.z_index = 10 # High z-index to ensure visibility # Add to Entities node for proper layering var entities_node = get_tree().get_first_node_in_group("game_world") @@ -1158,7 +1185,7 @@ func _spawn_smoke_puffs_on_open(): if not smoke_puff_scene: return - var puff_count = randi_range(1, 2) # Random between 1-2 puffs + var puff_count = randi_range(1, 2) # Random between 1-2 puffs for i in range(puff_count): var puff = smoke_puff_scene.instantiate() @@ -1167,7 +1194,7 @@ func _spawn_smoke_puffs_on_open(): var offset_x = randf_range(-8, 8) var offset_y = randf_range(-8, 8) puff.global_position = global_position + Vector2(offset_x, offset_y) - puff.z_index = 10 # High z-index to ensure visibility + puff.z_index = 10 # High z-index to ensure visibility # Add to Entities node for proper layering var entities_node = get_tree().get_first_node_in_group("game_world") @@ -1199,12 +1226,12 @@ func _are_all_switches_activated() -> bool: LogManager.log("Door: Switch " + str(switch.name) + " is NOT activated", LogManager.CATEGORY_DOOR) return false LogManager.log("Door: All connected switches are activated!", LogManager.CATEGORY_DOOR) - return true # All connected switches are activated + return true # All connected switches are activated # CRITICAL: If no switches are connected, the puzzle is NOT solved! # Switches should ALWAYS be connected when spawned - if they're not, it's an error LogManager.log("Door: WARNING - Door " + str(name) + " has no connected switches! Puzzle cannot be solved!", LogManager.CATEGORY_DOOR) - return false # No connected switches means puzzle is NOT solved + return false # No connected switches means puzzle is NOT solved func _on_key_interaction_area_body_entered(body): # Player entered key interaction area @@ -1228,7 +1255,7 @@ func _show_key_indicator(): key_indicator.visible = true # Make sure it's on top (higher z-index or add to front) key_indicator.z_index = 10 - move_child(key_indicator, get_child_count() - 1) # Move to front + move_child(key_indicator, get_child_count() - 1) # Move to front else: # Create key indicator if it doesn't exist yet _create_key_indicator() @@ -1244,13 +1271,13 @@ func teleportPlayer(body: Node2D): var keydoor_open_offset = Vector2.ZERO match direction: "Up": - keydoor_open_offset = Vector2(0, 16) # Open is 16px UP from closed + keydoor_open_offset = Vector2(0, 16) # Open is 16px UP from closed "Down": - keydoor_open_offset = Vector2(0, -16) # Open is 16px DOWN from closed (row 0 to row 1) + keydoor_open_offset = Vector2(0, -16) # Open is 16px DOWN from closed (row 0 to row 1) "Left": - keydoor_open_offset = Vector2(16, 0) # Open is 16px LEFT from closed + keydoor_open_offset = Vector2(16, 0) # Open is 16px LEFT from closed "Right": - keydoor_open_offset = Vector2(-16, 0) # Open is 16px RIGHT from closed + keydoor_open_offset = Vector2(-16, 0) # Open is 16px RIGHT from closed var new_position = self.global_position + keydoor_open_offset @@ -1284,6 +1311,8 @@ func _sync_door_open(): var is_actually_open = distance_to_closed > 5.0 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 if closed_position != Vector2.ZERO: position = closed_position diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index 3f57960..f2a67b1 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -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 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 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_MAX_WIDTH = 5 # Maximum width for door frames 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: 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 # 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. @@ -137,71 +145,121 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - var all_rooms = [] 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 - 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 = { - "x": rng.randi_range(4, map_size.x - first_w - 4), - "y": rng.randi_range(4, map_size.y - first_h - 4), - "w": first_w, - "h": first_h, - "modifiers": [] - } - - _set_floor(first_room, grid, tile_grid, map_size, rng) - all_rooms.append(first_room) - - # 2. Try to place rooms until we reach target count or can't fit any more - var attempts = 1000 - 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 new_room = _try_place_room_near(source_room, grid, map_size, rng) + if level == 4: + # --- 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. --- + # 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) - if new_room.w > 0: - _set_floor(new_room, grid, tile_grid, map_size, rng) - all_rooms.append(new_room) + # 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) - attempts -= 1 + # 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 = { + "x": rng.randi_range(4, map_size.x - first_w - 4), + "y": rng.randi_range(4, map_size.y - first_h - 4), + "w": first_w, + "h": first_h, + "modifiers": [] + } + _set_floor(first_room, grid, tile_grid, map_size, rng) + all_rooms.append(first_room) + var max_for_extra_rooms = max_room_size + var attempts = 1000 + 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 new_room = _try_place_room_near(source_room, grid, map_size, rng, max_for_extra_rooms, level) + 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: 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: _connect_rooms(all_rooms, grid, tile_grid, map_size, all_doors, rng) - # 4. Add random holes in some rooms (minimum 9x9 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) + # 4. (No holes - we only use sprinkled single fallout tiles and cracked tiles) - # 5. Mark start room (random room for variety) - var start_room_index = rng.randi() % all_rooms.size() + # 5 & 6. Start/exit and reachability + # 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 - all_rooms[start_room_index].modifiers.append({"type": "START"}) + 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"}) + 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) - # 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) - - # CRITICAL: Remove inaccessible rooms (rooms not reachable from start) - # Store the start room before filtering (it should always be reachable) - var start_room_ref = all_rooms[start_room_index] + # CRITICAL: Remove inaccessible rooms (rooms not in reachable_rooms) + var start_room_ref: Dictionary + if level != 4: + start_room_ref = all_rooms[start_room_index] var inaccessible_count = 0 - # Create new array with only reachable rooms - # Use value-based comparison (x, y, w, h) to check if room is reachable + # Create new array with only reachable rooms (value-based comparison) var filtered_rooms = [] - for room in all_rooms: + for idx in range(all_rooms.size()): + var room = all_rooms[idx] var is_reachable = false - # Check if this room is in the reachable_rooms list by comparing values for reachable_room in reachable_rooms: if reachable_room.x == room.x and reachable_room.y == room.y and \ reachable_room.w == room.w and reachable_room.h == room.h: is_reachable = true break - if is_reachable: filtered_rooms.append(room) else: @@ -214,18 +272,19 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - if inaccessible_count > 0: 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) - start_room_index = -1 - for i in range(all_rooms.size()): - var room = all_rooms[i] - if room.x == start_room_ref.x and room.y == start_room_ref.y and \ - room.w == start_room_ref.w and room.h == start_room_ref.h: - start_room_index = i - break - - if start_room_index == -1: - LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON) - start_room_index = 0 # Fallback + # 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 + for i in range(all_rooms.size()): + var room = all_rooms[i] + if room.x == start_room_ref.x and room.y == start_room_ref.y and \ + room.w == start_room_ref.w and room.h == start_room_ref.h: + start_room_index = i + break + if start_room_index == -1: + LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON) + start_room_index = 0 # Fallback # Also remove doors connected to inaccessible rooms (clean up all_doors) var filtered_doors = [] @@ -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: filtered_doors.append(door) 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 LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON) @@ -262,38 +323,56 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - 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) + # 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) - # Make sure we have at least 2 rooms (start and exit must be different) # exit_room_index is already declared at function level - if all_rooms.size() < 2: + if all_rooms.size() == 0: + LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_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) - # 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) - return {} # Return empty dungeon + exit_room_index = 0 else: exit_room_index = _find_farthest_room(all_rooms, start_room_index) - # Make sure exit room is different from start room - 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 second_farthest = -1 - for i in range(all_rooms.size()): - if i == start_room_index: - continue - var distance = abs(all_rooms[i].x - all_rooms[start_room_index].x) + abs(all_rooms[i].y - all_rooms[start_room_index].y) - if distance > max_distance: - max_distance = distance - second_farthest = i - if second_farthest != -1: - exit_room_index = second_farthest - - 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) + # 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: + var max_distance = 0 + var second_farthest = -1 + for i in range(all_rooms.size()): + if i == start_room_index: + continue + var distance = abs(all_rooms[i].x - all_rooms[start_room_index].x) + abs(all_rooms[i].y - all_rooms[start_room_index].y) + if distance > max_distance: + max_distance = distance + second_farthest = i + if second_farthest != -1: + exit_room_index = second_farthest + LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index), LogManager.CATEGORY_DUNGEON) # 7. Render walls around rooms _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) # 7.44. Sprinkle cracked floor (15,16) on some floor; never in doors, corridors, in front of doors, or in start room _fill_cracked_tile_grid(grid, cracked_tile_grid, map_size, all_doors, all_rooms[start_room_index], rng) - # 7.45. Sprinkle fallout tiles on some floor (cracked/worn look); never in doors, corridors, in front of doors, or in start room - _render_fallout_tiles(grid, tile_grid, map_size, all_doors, all_rooms[start_room_index], rng) + # 7.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, 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) _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) # 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 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] # Skip start room and exit room 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) var has_spawner_puzzle = false 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) 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) + # 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 = [] for i in range(all_rooms.size()): var room = all_rooms[i] # Skip start room and exit room 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) - # 9.6. Place traps (1-2 per level, excluding start and exit rooms) - var all_traps = _place_traps_in_dungeon(all_rooms, start_room_index, exit_room_index, grid, map_size, rng) + # 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, 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: 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 tile_grid[x][y] = FLOOR_BASE -func _try_place_room_near(source_room: Dictionary, grid: Array, map_size: Vector2i, rng: RandomNumberGenerator) -> Dictionary: - var attempts = 20 +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: + # 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: - var w = rng.randi_range(MIN_ROOM_SIZE, MAX_ROOM_SIZE) - var h = 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_param) # Try all four sides of the source room 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) 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: - grid[x][door_y + 1] = 3 # Corridor (middle row of door) - tile_grid[x][door_y + 1] = FLOOR_BASE + if x >= 0 and x < map_size.x and corridor_y >= 0 and corridor_y < map_size.y: + grid[x][corridor_y] = 3 # Corridor (middle row of door) + 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) # 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) 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: - grid[door_x + 1][y] = 3 # Corridor (middle column of door) - tile_grid[door_x + 1][y] = FLOOR_BASE + if corridor_x >= 0 and corridor_x < map_size.x and y >= 0 and y < map_size.y: + grid[corridor_x][y] = 3 # Corridor (middle column of door) + 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) # 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 -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: var start_room = all_rooms[start_index] var max_distance = 0 @@ -1119,6 +1302,171 @@ func _find_farthest_room(all_rooms: Array, start_index: int) -> int: 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: # Find the farthest room from the start room, but only from the reachable rooms list 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 false -func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, all_doors: Array, start_room: Dictionary, rng: RandomNumberGenerator): - # Replace a small fraction of floor tiles with fallout; never in doors, corridors, in front of doors, or in start room - const CHANCE = 0.08 +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, start room, or boss room. Level 4: much less fallout. + 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 y in range(map_size.y): if grid[x][y] != 1: @@ -1473,6 +1823,8 @@ func _render_fallout_tiles(grid: Array, tile_grid: Array, map_size: Vector2i, al continue if not start_room.is_empty() and _is_tile_in_room_interior(x, y, start_room): continue # No fallout tiles in start room + if 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: continue 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_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): for x in range(map_size.x): 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) 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 + # 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 # Medium rooms (9-10 tiles): 0-3 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 y in range(min_y, max_y + 1): 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 if grid[x][y] == 1: # Floor # 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 y in range(min_y, max_y + 1): 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 var world_x = x * 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) while object_type_data.type == "Pillar": object_type_data = object_types[rng.randi() % object_types.size()] - - objects.append({ + var obj_entry = { "type": object_type_data.type, "setup_function": object_type_data.setup, "position": valid_positions[positions_index], "room": room - }) + } + if object_type_data.type == "Chest" and rng.randf() < 0.4: + obj_entry["hidden"] = true + objects.append(obj_entry) positions_index += 1 else: # Normal placement: no pillar requirement for i in range(min(num_objects, valid_positions.size())): var object_type_data = object_types[rng.randi() % object_types.size()] var position = valid_positions[i] - - objects.append({ + var obj_entry = { "type": object_type_data.type, "setup_function": object_type_data.setup, "position": position, "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 for obj in objects: @@ -2414,7 +2795,7 @@ func _find_rooms_before_door(door: Dictionary, start_room: Dictionary, _all_room 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 # Returns array of blocking door data dictionaries 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 var _rooms_with_puzzles = {} # room -> true 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 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) for i in range(all_rooms.size()): 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] @@ -2524,16 +2931,18 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ room_index = j break - if room_index == start_room_index or room_index == exit_room_index: - LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start/exit room! Skipping.", LogManager.CATEGORY_DUNGEON) + var puzzle_info = room_puzzle_data[room] + 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 # CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check) 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) 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 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) else: 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 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" } - # 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 + 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) @@ -2738,6 +3158,11 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ door_data.requires_enemies = 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) + 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 if not door_has_valid_puzzle: @@ -3049,8 +3474,8 @@ func _determine_door_direction_for_puzzle_room(door: Dictionary, puzzle_room: Di else: 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: - # Place 1-2 traps in the dungeon (not in start or exit rooms) +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; never on fallout tiles) var traps = [] 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_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 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) var too_close_to_door = false # Simplified check - just ensure we're not right at door position diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index e159ed6..bc4142b 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -54,10 +54,19 @@ var anim_speed: float = 0.15 # Seconds per frame @onready var collision_shape = get_node_or_null("CollisionShape2D") 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_character_stats() - - current_health = max_health + # 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 add_to_group("enemy") # Setup shadow (if it exists - some enemies like humanoids don't have a Shadow node) @@ -72,6 +81,19 @@ func _ready(): # This allows enemies to collide with interactable objects so they can path around them # 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) + + # 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 # Override in subclasses to set specific baseStats @@ -127,70 +149,9 @@ func _physics_process(delta): burn_sprite.set_meta("burn_animation_timer", anim_timer) return - # Fallout: humanoid sinks like player (FALL anim) then dies; slime rotates 45 + scale then dies - if fallout_state: - velocity = Vector2.ZERO - fallout_scale_progress -= delta / FALLOUT_SINK_DURATION - if fallout_scale_progress <= 0.0: - died_from_fallout = true - call_deferred("_die") - return - scale = Vector2.ONE * max(0.0, fallout_scale_progress) - if has_method("_set_animation"): - _set_animation("FALL") - move_and_slide() + # 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 _check_and_handle_fallout(delta): return - if fallout_defeat_started: - velocity = Vector2.ZERO - fallout_scale_progress -= delta / FALLOUT_SINK_DURATION - if fallout_scale_progress <= 0.0: - died_from_fallout = true - call_deferred("_die") - return - scale = Vector2.ONE * max(0.0, fallout_scale_progress) - rotation = deg_to_rad(45.0) - move_and_slide() - return - - # Only ground enemies (position_z <= 0) can fall into fallout; bat has position_z 1 and ignores it - # Humanoid: use 16x16 box check (like player) so any part on fallout triggers; drag toward center then sink - var gw = get_tree().get_first_node_in_group("game_world") - var on_fallout = false - if position_z <= 0.0 and gw: - if "humanoid_type" in self and gw.has_method("_is_player_box_on_fallout_tile"): - on_fallout = gw._is_player_box_on_fallout_tile(global_position, 8.0) - elif gw.has_method("_is_position_on_fallout_tile"): - on_fallout = gw._is_position_on_fallout_tile(global_position) - if on_fallout: - if "humanoid_type" in self: - # Humanoid: drag toward tile center (quicksand pull) then sink when at center - var tile_center = gw._get_closest_fallout_tile_center(global_position) if gw.has_method("_get_closest_fallout_tile_center") else (gw._get_tile_center_at(global_position) if gw.has_method("_get_tile_center_at") else global_position) - var dist_to_center = global_position.distance_to(tile_center) - if dist_to_center < FALLOUT_CENTER_THRESHOLD: - global_position = tile_center - fallout_state = true - fallout_scale_progress = 1.0 - velocity = Vector2.ZERO - if has_method("_set_animation"): - _set_animation("FALL") - if has_node("SfxFallout"): - $SfxFallout.play() - else: - # Drag toward center (quicksand pull) - var dir = (tile_center - global_position).normalized() - const FALLOUT_DRAG_STRENGTH: float = 820.0 - velocity = dir * FALLOUT_DRAG_STRENGTH * get_process_delta_time() - if has_method("_set_animation"): - _set_animation("RUN") - move_and_slide() - return - else: - # Slime-like: rotate 45 and scale down then die - fallout_defeat_started = true - fallout_scale_progress = 1.0 - rotation = deg_to_rad(45.0) - move_and_slide() - return # Update attack timer if attack_timer > 0: @@ -211,8 +172,9 @@ func _physics_process(delta): if not is_knocked_back: _ai_behavior(delta) - # Slime, rat, humanoid: try to avoid stepping onto fallout (position_z <= 0 = ground enemies only; bat has position_z 1) - if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and velocity.length_squared() > 1.0: + # All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back) + # 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") if game_world and game_world.has_method("_is_position_on_fallout_tile"): 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") 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]) + # 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) 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 _remove_burn_debuff() - # Sync position and animation to clients (only server sends) - if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): - # Get state value if enemy has a state variable (for bats/slimes) - var state_val = -1 - if "state" in self: - state_val = get("state") as int - # Only send RPC if we're in the scene tree - if is_inside_tree(): - # Get enemy name/index for identification - var enemy_name = name - 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") - if game_world and game_world.has_method("_sync_enemy_position"): - # Send via game_world using enemy name/index and position for identification - 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) + # Sync position and animation to clients (only server sends). Also call from subclasses that override _physics_process (e.g. spider). + _send_position_sync_to_clients() + +# 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 + if "state" in self: + state_val = get("state") as int + if not is_inside_tree(): + return + var enemy_name = name + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_broadcast_enemy_position"): + 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]) + +# 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): # Override in subclasses @@ -528,6 +561,12 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals else: # Fallback: try direct RPC (may fail if node path doesn't match) _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: # Prevent multiple death triggers @@ -582,7 +621,7 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo else: 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.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) var direction_from_attacker = (global_position - from_position).normalized() diff --git a/src/scripts/enemy_hand.gd b/src/scripts/enemy_hand.gd index 743bfa8..d4d4fb8 100644 --- a/src/scripts/enemy_hand.gd +++ b/src/scripts/enemy_hand.gd @@ -8,6 +8,9 @@ enum HandState {HIDDEN, EMERGING, IDLE, GRABBING} var state: HandState = HandState.HIDDEN 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 random_move_dir: Vector2 = Vector2.ZERO 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 TILE_SIZE: int = 16 const TILE_STRIDE: int = 17 # 16 + separation 1 +@onready var detection_area: Area2D = $DetectionArea @onready var emerge_area: Area2D = $EmergeArea @onready var grab_area: Area2D = $GrabPlayerArea @onready var interest_area: Area2D = $PlayerInterestArea @@ -42,7 +46,7 @@ func _ready() -> void: # Start hidden modulate.a = 0.0 # 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: area.set_collision_mask_value(1, true) area.monitoring = true @@ -247,6 +251,59 @@ func _ai_behavior(delta: float) -> void: 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: if state != HandState.HIDDEN: return @@ -257,6 +314,8 @@ func _on_emerge_area_body_entered(body: Node2D) -> void: if not is_multiplayer_authority(): return + # Reveal effect is removed only when emerge animation finishes (_on_animation_finished) + state = HandState.EMERGING modulate.a = 1.0 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: if anim_name == "emerge": + _remove_detected_effect() # Remove reveal effect only once hand has fully emerged state = HandState.IDLE if anim_player and anim_player.has_animation("idle"): anim_player.play("idle") @@ -330,7 +390,7 @@ func _sync_hand_emerged(): # Sync hand emergence visibility to clients if is_multiplayer_authority(): return # Authority already handled it locally - + # Effect removed when emerge animation finishes on each client (_on_animation_finished) if state == HandState.HIDDEN: state = HandState.EMERGING modulate.a = 1.0 diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index 7fceb48..fa4b81a 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -60,7 +60,8 @@ var shield_block_chance: float = 0.0 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_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 spell_cooldown_timer: float = 0.0 var bomb_cooldown_timer: float = 0.0 @@ -766,7 +767,7 @@ func _load_random_headgear(): "SoldierIronHelmBlue.png", "SoldierSteelHelmBlue.png" ], "Basic Range": [ - "ArcherHatCyan.png", "HunterHatRed.png", "RangerHatGreen.png", "RogueHatGreen.png" + "ArcherHatCyan.png", "HunterHatRed.png", "RogueHatGreen.png" ], "French": ["MusketeerHatPurple.png"], "Japanese": [ @@ -978,10 +979,10 @@ func _assign_loadout(): elif spell_roll < 0.20: spell_type = "healing" 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: 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 if appearance_rng.randf() < 0.12: can_lift_throw = true @@ -1016,32 +1017,10 @@ func _get_nearby_liftable() -> Node: return best 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) - # Handle dead state (from parent) - 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 + # Humanoid-specific timers (base updates attack_timer; run before base AI) if spell_cooldown_timer > 0: spell_cooldown_timer -= delta if bomb_cooldown_timer > 0: @@ -1055,47 +1034,28 @@ func _physics_process(delta): if shield_block_timer <= 0: is_blocking = false _update_shield_visibility() - - # Update bow charge pulse timer when charging bow if ai_state == AIState.BOW_CHARGING and is_charging_attack: bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged _apply_bow_charge_tint() elif ai_state != AIState.BOW_CHARGING: - # Clear bow charge tint when not charging _clear_bow_charge_tint() - # Handle knockback - 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) + # Base handles: dead, not authority, fallout (drag/sink), knockback, attack_timer, _ai_behavior, avoidance, move_and_slide, collisions, burn + super._physics_process(delta) - # Enemy AI - only if not knocked back - if not is_knocked_back: - _ai_behavior(delta) + # Reset attack flags when cooldown expired (base decrements attack_timer only) + if attack_timer <= 0: + can_attack = true + is_attacking = false - # Move - 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) + # Sync position and animation to clients (only server sends; humanoid uses game_world RPC) 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") 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_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]) else: - # Fallback: try direct call to _sync_position (not RPC) _sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame) func _ai_behavior(delta): @@ -1249,28 +1209,19 @@ func _chasing_behavior(delta_arg): 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: - # Check if player is attacking (recently attacked or in melee range) - var player_is_attacking = false - if dist < 60.0: # Close enough that player might attack - # Check if player is facing us and might be attacking - if "is_attacking" in target_player and target_player.is_attacking: - 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: + # Only consider blocking when player is actually attacking (sword up), not just "close" + var player_is_attacking = (dist < 70.0 and "is_attacking" in target_player and target_player.is_attacking) + # Raise shield only when player is attacking (not when merely close) + if player_is_attacking and not is_blocking and randf() < 0.65: + # 65% chance to block when player is attacking (not 100%), so we attack sometimes 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() - # Play shield activation sound if sfx_activate_shield: sfx_activate_shield.play() 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 _update_shield_visibility() @@ -1320,6 +1271,9 @@ func _chasing_behavior(delta_arg): # --- Melee: close enough to 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) if is_blocking: 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: var to_attacker = (from_position - global_position).normalized() var facing = _get_attack_direction_vector() - # Check if attack is coming from the direction we're facing (blocking direction) - if to_attacker.dot(facing) > 0.5 and randf() < shield_block_chance: - # Successfully blocked - reduce damage - amount = amount * 0.5 - # Raise shield if not already blocking + var attack_from_front = to_attacker.dot(facing) > 0.5 + # Full block when already blocking and attack from front, or reactive block roll + var did_block = (is_blocking and attack_from_front) or (attack_from_front and randf() < shield_block_chance) + if did_block: + # Full block: no damage, show BLOCKED to attacker, small knockback on attacker, chance to lower our shield if not is_blocking: is_blocking = true - shield_block_timer = shield_block_duration + shield_block_timer = max(shield_block_duration, SHIELD_BLOCK_MIN_HOLD) _update_shield_visibility() if sfx_activate_shield: sfx_activate_shield.play() - # Play block sound + else: + shield_block_timer = max(shield_block_timer, SHIELD_BLOCK_MIN_HOLD) if sfx_block_with_shield: sfx_block_with_shield.play() - # Face the attacker 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: - # Attack not blocked, but raise shield anyway if we have one (defensive reaction) - if not is_blocking: + # Attack not blocked; brief defensive raise (shorter so they attack again sooner) + if not is_blocking and randf() < 0.5: 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() if sfx_activate_shield: sfx_activate_shield.play() diff --git a/src/scripts/enemy_spider.gd b/src/scripts/enemy_spider.gd new file mode 100644 index 0000000..f284c39 --- /dev/null +++ b/src/scripts/enemy_spider.gd @@ -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 diff --git a/src/scripts/enemy_spider.gd.uid b/src/scripts/enemy_spider.gd.uid new file mode 100644 index 0000000..e26c1a9 --- /dev/null +++ b/src/scripts/enemy_spider.gd.uid @@ -0,0 +1 @@ +uid://7e0vssvq87hn diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index bd1ae19..ec5f892 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -15,6 +15,9 @@ var screenshake_timer: float = 0.0 var screenshake_duration: 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 = [] const BASE_CAMERA_ZOOM: float = 4.0 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_current_scale: float = 1.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 combined_seen: PackedInt32Array = PackedInt32Array() 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) const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile before it breaks 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 const _FALLOUT_CENTER = Vector2i(10, 12) 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 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 var opened_chests: Dictionary = {} # object_index -> true @@ -182,6 +195,9 @@ var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, pla func _ready(): # Add to group for easy access 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 if network_manager: @@ -200,6 +216,19 @@ func _ready(): # Initialize mouse cursor system _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 # Only generate if we're the server (not just "no multiplayer peer") # 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(): _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) 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 # Wait a bit after dungeon sync to ensure spawners are spawned first 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 # 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) 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(): """Periodically check and remove disconnected peers from client_gameworld_ready""" 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 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) 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" _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") 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 @@ -1345,6 +1512,30 @@ func _sync_enemy_death(enemy_name: String, enemy_index: int): # This is okay, just log it 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") 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 @@ -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: enemy = child 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"): # Call the enemy's _sync_damage_visual method directly (not via RPC) @@ -1377,7 +1576,8 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou else: # Enemy not found - might already be freed or never spawned # This is okay, just log it - print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) + if enemy == null: + print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) @rpc("authority", "reliable") func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool): @@ -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 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") func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2): # 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 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(): # Check if browser tab is visible (web only) if OS.get_name() == "Web": @@ -1872,6 +2110,10 @@ func _process(delta): if use_mouse_control: _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) if not multiplayer.is_server(): 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. # 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()): + # 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 []) if players_to_check.is_empty() and player_manager: players_to_check = player_manager.get_local_players() @@ -2100,14 +2344,18 @@ func _update_camera(): else: 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 - for player in local_players: - center += player.position - center /= local_players.size() + if camera_override_target != Vector2.ZERO: + center = camera_override_target + else: + for player in local_players: + center += player.position + center /= local_players.size() - # Smooth camera movement (with screenshake) - camera.position = camera.position.lerp(center + screenshake_offset, 0.1) + # Smooth camera movement (with screenshake); slower lerp when overriding (boss intro - gentle move to boss) + 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) 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_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(): # Create cursor layer with high Z index cursor_layer = CanvasLayer.new() @@ -2568,6 +2891,88 @@ func _is_position_on_cracked_tile(world_pos: Vector2) -> bool: return false 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: if not dungeon_tilemap_layer: return Vector2i(-1, -1) @@ -2626,6 +3031,68 @@ func _play_whoosh_at(world_pos: Vector2) -> void: player.play() 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: # 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. @@ -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) if dungeon_tilemap_layer_decorated: 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: 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) + remove_detected_effect_at_position(tile_center) # Update dungeon_data so re-packed blob for joiners has correct floor (no separate broken list needed) if multiplayer.is_server() and not dungeon_data.is_empty(): if dungeon_data.has("tile_grid") and tile_x >= 0 and tile_y >= 0: @@ -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)) if dungeon_tilemap_layer_cracked: dungeon_tilemap_layer_cracked.erase_cell(Vector2i(tile_x, tile_y)) - var tile_center = dungeon_tilemap_layer.map_to_local(Vector2i(tile_x, tile_y)) + dungeon_tilemap_layer.global_position if dungeon_tilemap_layer else Vector2(tile_x * 16 + 8, tile_y * 16 + 8) + # 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) + remove_detected_effect_at_position(tile_center) func update_last_safe_position_for_player(player: Node, world_pos: Vector2) -> void: # Only store when position is on a non-fallout tile so we never remember a pit as safe @@ -2813,6 +3284,9 @@ func _init_fog_of_war(): _torch_darken_last_room_id = "" _torch_darken_target_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: 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 if exit_discovered: 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: var size = map_size.x * map_size.y @@ -3149,42 +3630,94 @@ func _median_torch_scale_from_rooms(rooms: Array) -> float: median = clampf(median, 0.0, 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: if dungeon_data.is_empty() or not dungeon_data.has("torches"): return var cm = get_node_or_null("CanvasModulate") if not cm or not is_instance_valid(cm): return - var local_list = player_manager.get_local_players() if player_manager else [] - if local_list.is_empty() or not local_list[0]: + # 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 - var p = local_list[0] - 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 in_room := not current_room.is_empty() - var room_id := "" - if in_room: - room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) - var transition := not _torch_darken_initialized - if _torch_darken_initialized: - if in_room != _torch_darken_in_room_last: - transition = true - elif in_room and room_id != _torch_darken_last_room_id: - transition = true - if transition: - _torch_darken_initialized = true - _torch_darken_in_room_last = in_room - _torch_darken_last_room_id = room_id + # Server or single player: compute target scale + 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]: + return + var p = local_list[0] + 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 in_room := not current_room.is_empty() + var room_id := "" if in_room: - var tc = clampi(_count_torches_in_room(current_room), 0, 4) - _torch_darken_target_scale = tc / 4.0 - else: - _torch_darken_target_scale = _median_torch_scale_from_rooms(cached_corridor_rooms) + room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) + var transition := not _torch_darken_initialized + if _torch_darken_initialized: + if in_room != _torch_darken_in_room_last: + transition = true + elif in_room and room_id != _torch_darken_last_room_id: + transition = true + if transition: + _torch_darken_initialized = true + _torch_darken_in_room_last = in_room + _torch_darken_last_room_id = room_id + if in_room: + var tc = clampi(_count_torches_in_room(current_room), 0, 4) + _torch_darken_target_scale = _torch_scale_to_display(tc / 4.0) + else: + _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() - _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) 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: if dungeon_data.is_empty() or not dungeon_data.has("torches"): 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 current_room = _find_room_at_tile(p_tile) var in_room := not current_room.is_empty() - var target: float + var raw: float if in_room: var tc = clampi(_count_torches_in_room(current_room), 0, 4) - target = tc / 4.0 + raw = tc / 4.0 else: - target = _median_torch_scale_from_rooms(cached_corridor_rooms) - var t := maxf(target, _TORCH_DARKEN_MIN_SCALE) - _torch_darken_target_scale = target - _torch_darken_current_scale = target + raw = _median_torch_scale_from_rooms(cached_corridor_rooms) + var t := _torch_scale_to_display(raw) + _torch_darken_target_scale = t + _torch_darken_current_scale = t var room_id := "" if in_room: 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: 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 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() @@ -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.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 = [ - 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(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(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(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 = [ - Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56), - Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38), - Color(0.28, 0.22, 0.32), Color(0.24, 0.18, 0.28), + Color(0.78, 0.52, 0.82), Color(0.82, 0.58, 0.86), Color(0.62, 0.42, 0.70), + Color(0.72, 0.48, 0.78), Color(0.68, 0.46, 0.74), Color(0.50, 0.42, 0.54), + 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 walls = [ @@ -3880,25 +4416,8 @@ func _render_dungeon(): var dt = decorated_tile_grid[x][y] if dt != null and dt is Vector2i: dungeon_tilemap_layer_decorated.set_cell(Vector2i(x, y), 0, dt) - if dungeon_data.has("cracked_tile_grid") and dungeon_tilemap_layer_cracked: - var cracked_tile_grid = dungeon_data.cracked_tile_grid - # Floor switch positions must NEVER show cracked ground - var switch_tiles = {} - if dungeon_data.has("blocking_doors"): - var bd = dungeon_data.blocking_doors - var bd_array = bd if bd is Array else (bd.doors if "doors" in bd else []) - for door_data in bd_array: - if "switch_tile_x" in door_data and "switch_tile_y" in door_data: - var k = str(door_data.switch_tile_x) + "," + str(door_data.switch_tile_y) - switch_tiles[k] = true - for x in range(map_size.x): - for y in range(map_size.y): - if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size(): - continue - if switch_tiles.has(str(x) + "," + str(y)): - continue - if cracked_tile_grid[x][y]: - dungeon_tilemap_layer_cracked.set_cell(Vector2i(x, y), 0, CRACKED_TILE_ATLAS) + # Cracked tiles: do NOT draw here; they start invisible and are revealed when a player detects them (perception roll) + # cracked_revealed_tiles and set_cell are used in _try_cracked_floor_detection() 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) @@ -5284,6 +5803,11 @@ func _reassemble_dungeon_blob(): _spawn_room_triggers() 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 if pending_door_states.size() > 0: print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...") @@ -5516,6 +6040,10 @@ func _sync_dungeon_entities(non_essential_data: Dictionary): # Spawn blocking doors and room triggers if not already spawned _spawn_blocking_doors() _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(): # Re-randomize appearance for all players that were spawned before dungeon_seed was received @@ -5705,6 +6233,9 @@ func _spawn_enemies(): # 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) + # 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 enemy.position = enemy_data.position # Add to scene tree AFTER setting authority, stats, and position @@ -5933,6 +6464,12 @@ func _spawn_interactable_objects(): else: 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 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) 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): # Sync broken interactable objects to new client with retry logic # 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): _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): # Show EXP number (green, using dmg_numbers.png font) at a specific position var damage_number_scene = preload("res://scenes/damage_number.tscn") @@ -9297,10 +9865,17 @@ func _spawn_blocking_doors(): door.requires_enemies = true door.requires_switch = false 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"]: door.requires_enemies = false 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) + 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.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: 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 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!") @@ -9835,6 +10415,18 @@ func _spawn_room_triggers(): LogManager.log("GameWorld: Added room trigger " + str(trigger.name) + " for room (" + str(room.x) + ", " + str(room.y) + ") - " + str(triggers_spawned) + "/" + str(rooms.size()), 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): # Place a key in the specified room (as loot) @@ -9857,14 +10449,16 @@ func _place_key_in_room(room: Dictionary): var tile_size = 16 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 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 dungeon_data.grid[x][y] == 1: # Floor var world_x = x * 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: # 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 \ 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: - # Connect door to trigger + # Connect door to trigger (avoid duplicate if room_trigger._find_room_entities already added it) door.room_trigger_area = trigger - # Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd) - trigger.doors_in_room.append(door) + if door not in trigger.doors_in_room: + trigger.doors_in_room.append(door) break diff --git a/src/scripts/ingame_hud.gd b/src/scripts/ingame_hud.gd index 1cefa7a..e384f78 100644 --- a/src/scripts/ingame_hud.gd +++ b/src/scripts/ingame_hud.gd @@ -16,6 +16,9 @@ var label_time: Label = null var label_time_value: Label = null var label_boss: Label = 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_player_count: 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_boss = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/LabelBoss") 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_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount") label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode") @@ -86,11 +105,13 @@ func _ready(): if not game_world: 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: texture_progress_bar_boss_hp.visible = false if label_boss: label_boss.visible = false + if center_bottom_boss: + center_bottom_boss.visible = false # Update host info display _update_host_info() @@ -387,32 +408,52 @@ func start_timer(): timer_running = true 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(): - # Find boss enemy (if any) + # Find boss enemy that is ACTIVATED (bar only shows when boss is activated) var boss_enemy = null var enemies = get_tree().get_nodes_in_group("enemy") 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"): + if enemy.has_meta("is_boss") and enemy.get_meta("is_boss") and enemy.has_meta("boss_activated") and enemy.get_meta("boss_activated"): boss_enemy = enemy break 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: - texture_progress_bar_boss_hp.visible = true + texture_progress_bar_boss_hp.visible = false 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 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: texture_progress_bar_boss_hp.max_value = max_health texture_progress_bar_boss_hp.value = health 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: texture_progress_bar_boss_hp.visible = false if label_boss: diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 45e358f..7b1e4bc 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -40,6 +40,13 @@ var chest_closed_frame: int = -1 var chest_opened_frame: int = -1 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 var sync_timer: float = 0.0 var sync_interval: float = 0.05 # Sync 20 times per second @@ -72,6 +79,94 @@ func _ready(): if shadow: shadow.modulate = Color(0, 0, 0, 0.5) 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): # All clients simulate physics locally for smooth visuals @@ -81,13 +176,15 @@ func _physics_process(delta): return if not is_frozen: - # Fallout: sink and disappear when on ground (not held, not airborne) - if not is_airborne and position_z <= 0.0: + # Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink. + if not is_airborne and position_z <= 0.0 and object_type != "Pillar": var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position): if not falling_into_fallout: falling_into_fallout = true fallout_sink_progress = 1.0 + if has_node("SfxFall"): + $SfxFall.play() fallout_sink_progress -= delta / FALLOUT_SINK_DURATION if fallout_sink_progress <= 0.0: queue_free() @@ -388,6 +485,9 @@ func _is_wall_collider(collider) -> bool: return false 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 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: """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: return health -= amount @@ -471,6 +574,9 @@ func take_damage(amount: float, _from_position: Vector2) -> void: _break_into_pieces() 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 if object_type == "Chest" and not is_chest_opened: # In multiplayer, send RPC to server if client is opening @@ -807,6 +913,13 @@ func _open_chest(by_player: Node = null): return $SfxOpenChest.play() 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 if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): @@ -860,69 +973,81 @@ func _open_chest(by_player: Node = null): if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON: candidates.append(item_id) - # Select random item from candidates using deterministic RNG - var random_item_id = candidates[chest_rng.randi() % candidates.size()] if not candidates.is_empty() else null + # Chance for empty chest (no item, no coin, no loot) + 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 - # CRITICAL: Instantly give item to player instead of spawning loot object - if by_player and is_instance_valid(by_player) and by_player.is_in_group("player") and chest_item: - # Add item to player inventory - if by_player.character_stats: - var was_encumbered = by_player.character_stats.is_over_encumbered() - by_player.character_stats.add_item(chest_item) - if not was_encumbered and by_player.character_stats.is_over_encumbered(): - if by_player.has_method("show_floating_status"): - by_player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) - - # Show pickup notification - var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null - var display_text = chest_item.item_name.to_upper() - var item_color = Color.WHITE - - # Determine color based on item type/rarity - if chest_item.item_type == Item.ItemType.Restoration: - item_color = Color.GREEN - elif chest_item.item_type == Item.ItemType.Equippable: - item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) - else: - item_color = Color.WHITE - - # Show notification with item sprite (pass chest_item for ItemSprite colorization) - if items_texture: - _show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) - else: - _show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item) - - # Play chest open sound - if has_node("SfxChestOpen"): - $SfxChestOpen.play() - - print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name) - - # Sync chest opening visual to all clients (item already given on server) - if multiplayer.has_multiplayer_peer(): - var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 - # Reuse game_world from earlier in the function - if game_world and game_world.has_method("_rpc_to_ready_peers"): - var chest_name = name - if has_meta("object_index"): - chest_name = "InteractableObject_%d" % get_meta("object_index") - # Sync chest open visual with item_data so clients can show the floating text - var item_data = chest_item.save() if chest_item else {} - game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data]) - # Sync inventory+equipment to joiner (server added item; joiner's client must apply) - if multiplayer.is_server(): - var owner_id = by_player.get_multiplayer_authority() - if owner_id != 1 and owner_id != multiplayer.get_unique_id(): - var inv_data: Array = [] - for inv_item in by_player.character_stats.inventory: - inv_data.append(inv_item.save() if inv_item else null) - var equip_data: Dictionary = {} - for slot_name in by_player.character_stats.equipment.keys(): - var eq = by_player.character_stats.equipment[slot_name] - equip_data[slot_name] = eq.save() if eq else null - 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) + # 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"): + if chest_item: + # Add item to player inventory + if by_player.character_stats: + var was_encumbered = by_player.character_stats.is_over_encumbered() + by_player.character_stats.add_item(chest_item) + if not was_encumbered and by_player.character_stats.is_over_encumbered(): + if by_player.has_method("show_floating_status"): + by_player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) + + # Show pickup notification + var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null + var display_text = chest_item.item_name.to_upper() + var item_color = Color.WHITE + + # Determine color based on item type/rarity + if chest_item.item_type == Item.ItemType.Restoration: + item_color = Color.GREEN + elif chest_item.item_type == Item.ItemType.Equippable: + item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) + else: + item_color = Color.WHITE + + # Show notification with item sprite (pass chest_item for ItemSprite colorization) + if items_texture: + _show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) + else: + _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 + if has_node("SfxGetItemFromChest"): + $SfxGetItemFromChest.play() + print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name) + # Sync chest opening visual to all clients (item already given on server) + if multiplayer.has_multiplayer_peer(): + var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 + 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") + 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]) + if multiplayer.is_server(): + var owner_id = by_player.get_multiplayer_authority() + if owner_id != 1 and owner_id != multiplayer.get_unique_id(): + var inv_data: Array = [] + for inv_item in by_player.character_stats.inventory: + inv_data.append(inv_item.save() if inv_item else null) + var equip_data: Dictionary = {} + for slot_name in by_player.character_stats.equipment.keys(): + var eq = by_player.character_stats.equipment[slot_name] + equip_data[slot_name] = eq.save() if eq else null + 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) + 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: 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) else: _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: # Fallback to old loot type system (for backwards compatibility) match loot_type_str: diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index 4aaa824..ec274d0 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -40,8 +40,8 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main # Bar layout constants (align X/Y + bar across rows) const _BAR_WIDTH: int = 100 -const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999" -const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:" +const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999" +const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:" # Weight UI elements (created programmatically) var weight_container: HBoxContainer = null @@ -257,18 +257,19 @@ func _update_stats(): str(char_stats.baseStats.lck) + "\n" + \ 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: - 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: - 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.defense, char_stats.move_speed, char_stats.attack_speed, char_stats.sight, char_stats.spell_amp, - char_stats.crit_chance + char_stats.crit_chance, + char_stats.dodge_chance * 100.0 ] # HP bar @@ -1042,7 +1043,7 @@ func _update_selection_from_navigation(): var row = inventory_rows_list[inventory_selection_row] print("InventoryUI: Checking item selection - row: ", inventory_selection_row, " col: ", inventory_selection_col, " row exists: ", row != null, " row child count: ", row.get_child_count() if row else 0) if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count(): - var items_per_row = 8 # Must match the items_per_row used when building rows + var items_per_row = 8 # Must match the items_per_row used when building rows var item_index = inventory_selection_row * items_per_row + inventory_selection_col print("InventoryUI: Calculated item_index: ", item_index, " inventory_items_list.size(): ", inventory_items_list.size()) if item_index >= 0 and item_index < inventory_items_list.size(): @@ -1368,7 +1369,7 @@ func _on_inventory_item_pressed(item: Item): _update_selection_highlight() _update_selection_rectangle() - _update_info_panel() # Show item description on single-click + _update_info_panel() # Show item description on single-click func _on_inventory_item_gui_input(event: InputEvent, item: Item): # Handle double-click to equip/consume and right-click to drop @@ -1679,6 +1680,9 @@ func _use_consumable_item(item: Item): if item.modifiers.has("mp"): var mana_amount = item.modifiers["mp"] 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) if index >= 0: @@ -1845,3 +1849,6 @@ func _lock_player_controls(lock: bool): var local_players = player_manager.get_local_players() for player in local_players: player.controls_disabled = lock + if lock: + # Stop movement immediately when opening inventory (physics may have already run this frame) + player.velocity = Vector2.ZERO diff --git a/src/scripts/inventory_ui_refactored.gd b/src/scripts/inventory_ui_refactored.gd index 6039be2..9b62a82 100644 --- a/src/scripts/inventory_ui_refactored.gd +++ b/src/scripts/inventory_ui_refactored.gd @@ -186,7 +186,8 @@ func _update_stats(): str(char_stats.attack_speed) + "\n" + \ str(char_stats.sight) + "\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(): # Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index 15d9259..22935c6 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -966,10 +966,7 @@ static func _load_all_items(): "description": "A standard longsword", "item_type": Item.ItemType.Equippable, "equipment_type": Item.EquipmentType.MAINHAND, - "weapon_type": Item.WeaponType.SWORD, - "spriteFrame": 3 * 20 + 10, # 10,3 "modifiers": {"dmg": 5}, - "buy_cost": 100, "sell_worth": 30, "rarity": ItemRarity.COMMON, "weight": 3.5 diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 1e28dac..24c55d8 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -58,7 +58,7 @@ var item: Item = null # Item instance (for LootType.ITEM) # Quantity badge for items with quantity > 1 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 fallout_sink_progress: float = 1.0 const FALLOUT_SINK_DURATION: float = 0.4 @@ -234,6 +234,8 @@ func _physics_process(delta): if not falling_into_fallout: falling_into_fallout = true fallout_sink_progress = 1.0 + if has_node("SfxFall"): + $SfxFall.play() # Lock to center of fallout tile and stop all x/y movement if gw.has_method("_get_tile_center_at"): global_position = gw._get_tile_center_at(global_position) @@ -244,13 +246,26 @@ func _physics_process(delta): pickup_area.monitorable = false fallout_sink_progress -= delta / FALLOUT_SINK_DURATION if fallout_sink_progress <= 0.0: - # Sync removal to clients so joiner sees loot disappear (same as pickup) - if multiplayer.has_multiplayer_peer() and is_inside_tree(): - var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 + # 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("_sync_loot_remove"): - game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) - queue_free() + 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) + if multiplayer.has_multiplayer_peer() and is_inside_tree(): + var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_remove"): + game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) + queue_free() return scale = Vector2.ONE * max(0.0, fallout_sink_progress) move_and_slide() @@ -447,6 +462,20 @@ func _animate_coin(delta): var frame = int(coin_anim_time) % 6 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): if falling_into_fallout: return diff --git a/src/scripts/minimap.gd b/src/scripts/minimap.gd index 0c79e0c..c05a704 100644 --- a/src/scripts/minimap.gd +++ b/src/scripts/minimap.gd @@ -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_FLOOR: Color = Color(0.38, 0.38, 0.44) 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) var _map_size: Vector2i = Vector2i.ZERO var _explored_map: PackedInt32Array = PackedInt32Array() var _grid: Array = [] # 2D grid [x][y]: 0=wall, 1=floor, 2=door, 3=corridor 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_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 _map_size = map_size _grid = grid _player_tile = player_tile + _other_player_tiles = other_player_tiles _exit_tile = exit_tile _exit_discovered = exit_discovered queue_redraw() @@ -73,6 +76,12 @@ func _draw() -> void: var py := float(_player_tile.y) * th + th * 0.5 var r := maxf(2.0, minf(tw, th) * 0.4) 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: var ex := float(_exit_tile.x) * tw + tw * 0.5 var ey := float(_exit_tile.y) * th + th * 0.5 diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 8649e91..8168f89 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -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 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 func log_print(message: String): LogManager.log(message, LogManager.CATEGORY_NETWORK) @@ -69,6 +73,23 @@ func _ready(): add_child(room_registry) 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): # 0 = ENet, 1 = WebRTC, 2 = WebSocket # 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 func _on_peer_connected(id: int): 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) if not players_info.has(id): # 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 "player_names": _generate_player_names(1, id) } - # 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 log_print("NetworkManager: Joiner - host connected via multiplayer.peer_connected, emitting connection_succeeded") connection_succeeded.emit() - # Emit player connected signal (peer is now available for RPCs) player_connected.emit(id, players_info[id]) - # Update room registry if hosting (player count changed) if is_hosting and room_registry and not room_id.is_empty(): var player_count = get_all_player_ids().size() @@ -373,20 +399,22 @@ func _on_peer_connected(id: int): # Called when a peer disconnects func _on_peer_disconnected(id: int): log_print("Peer disconnected: " + str(id)) - # Get player_info before erasing it - 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 joiner lost host, trigger server disconnection immediately (no grace) if not is_hosting and id == 1: log_print("NetworkManager: Host (peer ID 1) disconnected, triggering reconnection...") _on_server_disconnected() return - - # Update room registry if hosting (player count changed) + # Host: when a joiner disconnects, use grace period so brief drops don't "recreate" them as new + 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(): var player_count = get_all_player_ids().size() var _level = 1 @@ -422,6 +450,9 @@ func _on_connection_failed(): # Called on client when disconnected from server func _on_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) 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, # 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 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) if has_meta("connection_succeeded_timer_set"): 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") current_tree.change_scene_to_packed(game_world_scene) 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 @@ -747,7 +787,17 @@ func get_room_id() -> String: return room_id 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: reconnection_timer -= delta if reconnection_timer <= 0.0: @@ -827,6 +877,43 @@ func _generate_room_id() -> String: code += chars[randi() % chars.length()] 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: if network_mode == 1 and multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: diff --git a/src/scripts/player.gd b/src/scripts/player.gd index ebdca95..6d78583 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -72,6 +72,10 @@ var is_knocked_back: bool = false var knockback_time: float = 0.0 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 var can_attack: bool = true 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_look_out = $SfxLookOut @onready var sfx_ahaa = $SfxAhaa +@onready var sfx_secret_found = $SfxSecretFound # Alert indicator (exclamation mark) 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) # 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() params.shape = placed_shape params.transform = placed_shape_transform 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 - # The object might still be in the scene tree with collision disabled, so we need to exclude it - var exclude_list = [self] - if placed_obj and is_instance_valid(placed_obj): - exclude_list.append(placed_obj) + # CRITICAL: Exclude using RIDs so the physics engine actually excludes them (Node refs may not work) + var exclude_list: Array[RID] = [get_rid()] + if placed_obj and is_instance_valid(placed_obj) and placed_obj is CollisionObject2D: + exclude_list.append(placed_obj.get_rid()) params.exclude = exclude_list # Test the actual collision shape at the placement position var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions - # Debug: Log what we found - if hits.size() > 0: - print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos) - for i in min(hits.size(), 3): # Log first 3 collisions - var hit = hits[i] - if hit.has("collider"): - print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")") - if hit.has("rid"): - print(" - RID: ", hit.rid) - # If any collisions found, placement is invalid return hits.size() == 0 @@ -1964,6 +1960,8 @@ func _physics_process(delta): const MANA_REGEN_RATE = 2.0 # mana per second if character_stats.mp < character_stats.maxmp: 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 if is_charging_spell: @@ -2496,8 +2494,8 @@ func _handle_input(): if new_direction != current_direction: current_direction = new_direction _update_cone_light_rotation() - elif is_pushing: - # Keep locked direction when pushing + elif is_pushing or (held_object and not is_lifting): + # Keep direction from when grab started (don't turn to face the object) if push_direction_locked != current_direction: current_direction = push_direction_locked as Direction _update_cone_light_rotation() @@ -2539,9 +2537,10 @@ func _handle_input(): elif is_lifting: if current_animation != "LIFT" and current_animation != "IDLE_HOLD": _set_animation("IDLE_HOLD") - elif is_pushing: - _set_animation("IDLE_PUSH") - # Keep locked direction when pushing + elif is_pushing or (held_object and not is_lifting): + if is_pushing: + _set_animation("IDLE_PUSH") + # Keep direction from when grab started if push_direction_locked != current_direction: current_direction = push_direction_locked as Direction _update_cone_light_rotation() @@ -2598,8 +2597,8 @@ func _handle_input(): if character_stats and character_stats.is_over_encumbered(): current_speed = base_speed * 0.25 - # Lock movement if movement_lock_timer is active or reviving a corpse - if movement_lock_timer > 0.0 or is_reviving: + # Lock movement if movement_lock_timer is active, reviving a corpse, or netted by web + if movement_lock_timer > 0.0 or is_reviving or netted_by_web: velocity = Vector2.ZERO else: velocity = input_vector * current_speed @@ -2623,6 +2622,32 @@ func _handle_walking_sfx(): if sfx_walk and sfx_walk.playing: 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(): var grab_button_down = false var grab_just_pressed = false @@ -3192,7 +3217,7 @@ func _handle_interactions(): # Handle bow charging 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: $SfxBuckleBow.play() # Start charging bow @@ -3260,7 +3285,7 @@ func _handle_interactions(): # Normal attack (non-bow or no arrows) # 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: # Attack while lifting -> throw immediately in facing direction _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) grab_distance = global_position.distance_to(closest_body.global_position) - # Calculate push axis from grab direction (but don't move the object yet) - var grab_direction = grab_offset.normalized() + # Use player's current facing when grab started (do not turn to face the object) + 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: - grab_direction = last_movement_direction + grab_direction = Vector2.DOWN 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 # But keep collision with walls (layer 7) enabled for pushing @@ -3503,15 +3529,11 @@ func _start_pushing(): is_pushing = true is_lifting = false - # Lock to the direction we're facing when we start pushing - var initial_direction = grab_offset.normalized() + # Keep the direction we had when we started the grab (do not face the object) + 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: - initial_direction = last_movement_direction.normalized() - - # Snap to one of 8 directions + initial_direction = Vector2.DOWN 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) facing_direction_vector = push_axis.normalized() @@ -4045,7 +4067,7 @@ func _place_down_object(): print("Placed down ", placed_obj.name, " at ", place_pos) 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 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) if fallout_state: 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 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(): _rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position]) - # Check if dead - but wait for damage animation to play first - # Use small epsilon to handle floating point precision issues (HP might be 0.0000001 instead of exactly 0.0) + # Check if dead - below 1 HP must always trigger death (trap, etc.) 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: character_stats.hp = 0.0 # Clamp to exactly 0 else: @@ -6563,7 +6591,8 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool is_dead = true # Set flag immediately to prevent more damage # Wait a bit for damage animation and knockback to show await get_tree().create_timer(0.3).timeout - _die() + if is_instance_valid(self) and is_dead: + _die() func _die(): # Already processing death - prevent multiple concurrent death sequences @@ -7575,6 +7604,10 @@ func _on_level_up_stats(stats_increased: Array): if not character_stats: 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 var stat_display_names = { "str": "STR", @@ -7651,6 +7684,17 @@ func rpc_apply_corpse_knockback(dir_x: float, dir_y: float, force: float): is_knocked_back = true 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") 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 @@ -7796,6 +7840,26 @@ func _sync_trap_detected_alert(): if sfx_look_out: 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") func _sync_exit_found_alert(): # Sync exit found alert to all clients diff --git a/src/scripts/sword_projectile.gd b/src/scripts/sword_projectile.gd index c3b44ca..3b8e7e8 100644 --- a/src/scripts/sword_projectile.gd +++ b/src/scripts/sword_projectile.gd @@ -15,15 +15,19 @@ var elapsed_time: float = 0.0 var distance_traveled: float = 0.0 var player_owner: Node = null var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) +var _cut_web_rpc_sent: bool = false @onready var sprite = $Sprite2D @onready var hit_area = $Area2D func _ready(): $SfxSwosh.play() - # Connect area signals (only if not already connected) - if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): - hit_area.body_entered.connect(_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) + 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): travel_direction = direction.normalized() @@ -51,6 +55,13 @@ func _physics_process(delta): # Decelerate quickly current_speed -= deceleration * delta 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 var movement = travel_direction * current_speed * delta @@ -62,6 +73,11 @@ func _physics_process(delta): if sprite: 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): # Don't hit the owner if body == player_owner: @@ -134,6 +150,9 @@ func _on_body_entered(body): else: # Client sends RPC to server 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: # Fallback: try direct call (may fail if node path doesn't match) var enemy_peer_id = body.get_multiplayer_authority() diff --git a/src/scripts/teleporter_into_closed_room.gd b/src/scripts/teleporter_into_closed_room.gd index b5ad5de..98c897e 100644 --- a/src/scripts/teleporter_into_closed_room.gd +++ b/src/scripts/teleporter_into_closed_room.gd @@ -1,10 +1,16 @@ 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. 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. diff --git a/src/scripts/trap.gd b/src/scripts/trap.gd index c038444..e43428d 100644 --- a/src/scripts/trap.gd +++ b/src/scripts/trap.gd @@ -7,11 +7,11 @@ extends Node2D @onready var detection_area = $DetectionArea # Trap state -var is_detected: bool = false # Becomes true when any player detects it -var is_disarmed: bool = false # True if trap has been disarmed -var is_active: bool = false # True when trap is currently triggering -var has_cooldown: bool = false # Some traps can reset -var cooldown_time: float = 5.0 # Time until trap can re-activate +var is_detected: bool = false # Becomes true when any player detects it +var is_disarmed: bool = false # True if trap has been disarmed +var is_active: bool = false # True when trap is currently triggering +var has_cooldown: bool = false # Some traps can reset +var cooldown_time: float = 5.0 # Time until trap can re-activate var cooldown_timer: float = 0.0 # Trap properties @@ -44,7 +44,7 @@ func _ready() -> void: if index == 0: sprite.texture = load("res://assets/gfx/traps/Floor_Lance.png") trap_type = "Floor_Lance" - has_cooldown = true # Lance traps can reset + has_cooldown = true # Lance traps can reset # Start hidden (invisible until detected) sprite.modulate.a = 0.0 @@ -91,14 +91,14 @@ func _on_detection_area_body_entered(body: Node) -> void: return if is_detected or is_disarmed: - return # Already detected or disarmed + return # Already detected or disarmed # Get player peer ID var peer_id = body.get_multiplayer_authority() # Check if this player has already attempted detection if player_detection_attempts.has(peer_id): - return # Already tried once this game + return # Already tried once this game # Mark that this player has attempted detection player_detection_attempts[peer_id] = true @@ -108,7 +108,7 @@ func _on_detection_area_body_entered(body: Node) -> void: _roll_perception_check(body) func _on_detection_area_body_exited(_body: Node) -> void: - pass # Detection is permanent once attempted + pass # Detection is permanent once attempted func _roll_perception_check(player: Node) -> void: # Roll perception check for player @@ -119,8 +119,8 @@ func _roll_perception_check(player: Node) -> void: # Perception roll: d20 + PER modifier # Target DC: 15 (medium difficulty) - var roll = randi() % 20 + 1 # 1d20 - var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2 + var roll = randi() % 20 + 1 # 1d20 + var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2 var dc = 15 print(player.name, " rolls Perception: ", roll, " + ", int(per_stat / 2) - 5, " = ", total, " vs DC ", dc) @@ -143,15 +143,20 @@ func _detect_trap(detecting_player: Node) -> void: if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"): 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) # CRITICAL: Validate trap is still valid before sending RPC # Use GameWorld RPC to avoid node path issues if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): if multiplayer.is_server(): # 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"): - game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false + game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false print(detecting_player.name, " detected trap at ", global_position) @@ -200,7 +205,7 @@ func _on_disarm_area_body_exited(body: Node) -> void: func _show_disarm_text(_player: Node) -> void: # Create "DISARM" label above trap if disarm_label: - return # Already showing + return # Already showing disarm_label = Label.new() disarm_label.text = "DISARM" @@ -212,6 +217,13 @@ func _show_disarm_text(_player: Node) -> void: disarm_label.z_index = 100 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: if disarm_label: disarm_label.queue_free() @@ -246,6 +258,12 @@ func _complete_disarm() -> void: if $SfxDisarming.playing: $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(null) @@ -267,7 +285,7 @@ func _complete_disarm() -> void: # Grant EXP to all players for disarming trap (only on server) # CRITICAL: Only server should grant EXP to avoid duplicates if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): - var trap_exp_reward = 8.0 # EXP reward for disarming a trap + var trap_exp_reward = 8.0 # EXP reward for disarming a trap var all_players = get_tree().get_nodes_in_group("player") var valid_players = [] for player in all_players: @@ -303,7 +321,7 @@ func _complete_disarm() -> void: if multiplayer.is_server(): # Host disarmed: broadcast to clients if game_world.has_method("_sync_trap_state_by_name"): - game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true + game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true else: # Joiner disarmed: request host to apply locally and broadcast to all if game_world.has_method("_request_trap_disarm"): @@ -318,6 +336,7 @@ func _sync_trap_disarmed() -> void: if not is_instance_valid(self) or not is_inside_tree(): return is_disarmed = true + _remove_detected_effect() if sprite: sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) if activation_area: @@ -335,7 +354,7 @@ func _show_exp_number(amount: float, exp_pos: Vector2): # Set text and color for EXP (green) exp_label.label = "+" + str(int(amount)) + " EXP" - exp_label.color = Color(0.4, 1.0, 0.4) # Bright green + exp_label.color = Color(0.4, 1.0, 0.4) # Bright green exp_label.z_index = 5 # Direction is straight up @@ -347,7 +366,7 @@ func _show_exp_number(amount: float, exp_pos: Vector2): var entities_node = game_world.get_node_or_null("Entities") if entities_node: entities_node.add_child(exp_label) - exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above + exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above else: get_tree().current_scene.add_child(exp_label) exp_label.global_position = exp_pos + Vector2(0, -20) @@ -371,10 +390,10 @@ func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_ return if is_disarmed or is_active: - return # Can't trigger if disarmed or already active + return # Can't trigger if disarmed or already active if has_cooldown and cooldown_timer > 0: - return # Still on cooldown + return # Still on cooldown # Trigger trap is_active = true @@ -392,7 +411,7 @@ func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_ # Use GameWorld RPC with trap name instead of path var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_trap_state_by_name"): - game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false + game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false # Deal damage to player (with luck-based avoidance) _deal_trap_damage(body) @@ -414,7 +433,7 @@ func _deal_trap_damage(player: Node) -> void: # Luck-based avoidance check var luck_stat = player.character_stats.baseStats.lck + player.character_stats.get_pass("lck") - var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid) + var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid) var avoid_roll = randf() if avoid_roll < avoid_chance: @@ -424,10 +443,16 @@ func _deal_trap_damage(player: Node) -> void: _show_floating_text("AVOIDED", Color.GREEN) 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) if player.has_method("rpc_take_damage"): - player.rpc_take_damage(trap_damage, global_position) + var pid = player.get_multiplayer_authority() + if multiplayer.get_unique_id() == pid: + player.take_damage(trap_damage, global_position) + elif multiplayer.has_multiplayer_peer() and pid != 0: + player.rpc_take_damage.rpc_id(pid, trap_damage, global_position) + else: + player.rpc_take_damage(trap_damage, global_position) print(player.name, " took ", final_damage, " trap damage")