From fa7e9693636098ef208d5e49e5e1a0d1250cec54 Mon Sep 17 00:00:00 2001 From: Elrinth Date: Fri, 6 Feb 2026 02:49:58 +0100 Subject: [PATCH] fix spider bat boss alittle --- .../boss/spider_bat_boss/spawn_spider.wav | Bin 0 -> 3846 bytes .../spider_bat_boss/spawn_spider.wav.import | 24 + .../enemies/boss/spider_bat_boss/webbed.wav | Bin 0 -> 23149 bytes .../boss/spider_bat_boss/webbed.wav.import | 24 + .../audio/sfx/z3/Dungeon Teleporting.wav | Bin 0 -> 117914 bytes .../sfx/z3/Dungeon Teleporting.wav.import | 24 + src/assets/audio/sfx/z3/Hidden Treasure.wav | Bin 0 -> 71798 bytes .../audio/sfx/z3/Hidden Treasure.wav.import | 24 + .../sfx/z3/LA_Dungeon_Teleport_Appear.wav | Bin 0 -> 48538 bytes .../z3/LA_Dungeon_Teleport_Appear.wav.import | 24 + src/assets/audio/sfx/z3/LA_Enemy_Fall.wav | Bin 0 -> 31600 bytes .../audio/sfx/z3/LA_Enemy_Fall.wav.import | 24 + src/assets/audio/sfx/z3/LA_Enemy_Jump.wav | Bin 0 -> 19582 bytes .../audio/sfx/z3/LA_Enemy_Jump.wav.import | 24 + .../audio/sfx/z3/LA_Fanfare_Item_Extended.wav | Bin 0 -> 122610 bytes .../z3/LA_Fanfare_Item_Extended.wav.import | 24 + src/assets/audio/sfx/z3/LA_Get_Item2.wav | Bin 0 -> 28442 bytes .../audio/sfx/z3/LA_Get_Item2.wav.import | 24 + src/assets/audio/sfx/z3/LA_Ground_Crumble.wav | Bin 0 -> 46408 bytes .../audio/sfx/z3/LA_Ground_Crumble.wav.import | 24 + src/assets/audio/sfx/z3/LA_Link_Fall.wav | Bin 0 -> 49298 bytes .../audio/sfx/z3/LA_Link_Fall.wav.import | 24 + .../audio/sfx/z3/LA_NightmareShadows_Move.wav | Bin 0 -> 68300 bytes .../z3/LA_NightmareShadows_Move.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Charge.wav | Bin 0 -> 45006 bytes .../audio/sfx/z3/LA_Sword_Charge.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Slash1.wav | Bin 0 -> 6194 bytes .../audio/sfx/z3/LA_Sword_Slash1.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Slash2.wav | Bin 0 -> 6914 bytes .../audio/sfx/z3/LA_Sword_Slash2.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Slash3.wav | Bin 0 -> 6812 bytes .../audio/sfx/z3/LA_Sword_Slash3.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Slash4.wav | Bin 0 -> 7350 bytes .../audio/sfx/z3/LA_Sword_Slash4.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Spin.wav | Bin 0 -> 27142 bytes .../audio/sfx/z3/LA_Sword_Spin.wav.import | 24 + src/assets/audio/sfx/z3/LA_Sword_Tap.wav | Bin 0 -> 9220 bytes .../audio/sfx/z3/LA_Sword_Tap.wav.import | 24 + .../audio/sfx/z3/LA_Sword_Tap_Bombable.wav | Bin 0 -> 20506 bytes .../sfx/z3/LA_Sword_Tap_Bombable.wav.import | 24 + .../Layer 3 - Gloves/GlovesBrown.png | Bin 3655 -> 7805 bytes .../Layer 3 - Gloves/GlovesLightBrown.png | Bin 3675 -> 7910 bytes .../Layer 3 - Gloves/GlovesMaple.png | Bin 3725 -> 7891 bytes .../Layer 3 - Gloves/IronGloves.png | Bin 3509 -> 7669 bytes src/scenes/attack_web_shot.tscn | 27 + src/scenes/boss_room_test.tscn | 23 +- src/scenes/boss_spider_bat.tscn | 442 ++++++++++ src/scenes/detected_effect.tscn | 31 + src/scenes/enemy_hand.tscn | 11 + src/scenes/enemy_humanoid.tscn | 2 + src/scenes/enemy_slime.tscn | 6 + src/scenes/enemy_spider.tscn | 29 + src/scenes/ingame_hud.tscn | 38 + src/scenes/interactable_object.tscn | 25 + src/scenes/loot.tscn | 7 + src/scenes/player.tscn | 38 +- src/scenes/trap.tscn | 24 + src/scripts/attack_axe_swing.gd | 18 + src/scripts/attack_punch.gd | 26 +- src/scripts/attack_web_shot.gd | 172 ++++ src/scripts/attack_web_shot.gd.uid | 1 + src/scripts/boss_spider_bat.gd | 417 ++++++++++ src/scripts/boss_spider_bat.gd.uid | 1 + src/scripts/character_stats.gd | 105 ++- src/scripts/detected_effect.gd | 68 ++ src/scripts/detected_effect.gd.uid | 1 + src/scripts/door.gd | 229 +++--- src/scripts/dungeon_generator.gd | 765 ++++++++++++++---- src/scripts/enemy_base.gd | 215 +++-- src/scripts/enemy_hand.gd | 64 +- src/scripts/enemy_humanoid.gd | 130 ++- src/scripts/enemy_spider.gd | 170 ++++ src/scripts/enemy_spider.gd.uid | 1 + src/scripts/game_world.gd | 742 +++++++++++++++-- src/scripts/ingame_hud.gd | 61 +- src/scripts/interactable_object.gd | 253 ++++-- src/scripts/inventory_ui.gd | 23 +- src/scripts/inventory_ui_refactored.gd | 3 +- src/scripts/item_database.gd | 3 - src/scripts/loot.gd | 43 +- src/scripts/minimap.gd | 11 +- src/scripts/network_manager.gd | 125 ++- src/scripts/player.gd | 144 +++- src/scripts/sword_projectile.gd | 25 +- src/scripts/teleporter_into_closed_room.gd | 10 +- src/scripts/trap.gd | 73 +- 86 files changed, 4319 insertions(+), 763 deletions(-) create mode 100644 src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav create mode 100644 src/assets/audio/sfx/enemies/boss/spider_bat_boss/spawn_spider.wav.import create mode 100644 src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav create mode 100644 src/assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav.import create mode 100644 src/assets/audio/sfx/z3/Dungeon Teleporting.wav create mode 100644 src/assets/audio/sfx/z3/Dungeon Teleporting.wav.import create mode 100644 src/assets/audio/sfx/z3/Hidden Treasure.wav create mode 100644 src/assets/audio/sfx/z3/Hidden Treasure.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav create mode 100644 src/assets/audio/sfx/z3/LA_Dungeon_Teleport_Appear.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Enemy_Fall.wav create mode 100644 src/assets/audio/sfx/z3/LA_Enemy_Fall.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Enemy_Jump.wav create mode 100644 src/assets/audio/sfx/z3/LA_Enemy_Jump.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav create mode 100644 src/assets/audio/sfx/z3/LA_Fanfare_Item_Extended.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Get_Item2.wav create mode 100644 src/assets/audio/sfx/z3/LA_Get_Item2.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Ground_Crumble.wav create mode 100644 src/assets/audio/sfx/z3/LA_Ground_Crumble.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Link_Fall.wav create mode 100644 src/assets/audio/sfx/z3/LA_Link_Fall.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav create mode 100644 src/assets/audio/sfx/z3/LA_NightmareShadows_Move.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Charge.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Charge.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash1.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash1.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash2.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash2.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash3.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash3.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash4.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Slash4.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Spin.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Spin.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Tap.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Tap.wav.import create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav create mode 100644 src/assets/audio/sfx/z3/LA_Sword_Tap_Bombable.wav.import create mode 100644 src/scenes/attack_web_shot.tscn create mode 100644 src/scenes/boss_spider_bat.tscn create mode 100644 src/scenes/detected_effect.tscn create mode 100644 src/scenes/enemy_spider.tscn create mode 100644 src/scripts/attack_web_shot.gd create mode 100644 src/scripts/attack_web_shot.gd.uid create mode 100644 src/scripts/boss_spider_bat.gd create mode 100644 src/scripts/boss_spider_bat.gd.uid create mode 100644 src/scripts/detected_effect.gd create mode 100644 src/scripts/detected_effect.gd.uid create mode 100644 src/scripts/enemy_spider.gd create mode 100644 src/scripts/enemy_spider.gd.uid 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 0000000000000000000000000000000000000000..0e59e616544435bf70d4f0a1c8e3455b2dcc4475 GIT binary patch literal 3846 zcmZ`+*^=W#5cIzBMDPjx3K6goyz&6A%#6qPE!$&T@*&H&EZ@g?j@|tLzKD+@$gJx2 zu`CHI*_!UktgLQz`L|y`e*EYASnQ9Ve*Yzrum1Q$EEfBK|9<`}CjUOfzKg~0s&{{5 zO!t4$`!@V5e0v0+eigNIjrLc#{Ovr;1~y!MMer5OW8BV~>_ijEgf@hp!WFa>`bGXA zqhyB6mQV~#8etv)Ltu0UzYY0#Fi>qGNz}-62_zvITv$jb)J>oPJQ4iB2lxvIa3m&R zCLvDGR!GH+M)c$?C5je{rC_m$A}BMe#3|Y&RihxfBi>LgrD}ptbcuz79u`d0&>CnlQdk-Y z5go;1p^(oh^7%rc2tAO)vQdqS!P0>wO@LEmSFttRqAddLn`$;9I| z$OUUbh2)SMn0h2aV&NP-o9+Jad_(i{LY~cC_EH{4tRvN3q0g z8ZB2DkO>La+ueWSpN@y!mhoo@563!f7b#RL68u_!96<1F4*H$iQ={c|x_$udv-x7R z-tKSxfmNsD9)Y89Iv(!nR5XWC%wQyILuv%qz=$=;Wx-X!MS(Suh(Fx_X>g~wzmGpA zQ>c~XM*eoU*N1si2taDE?&bCU{r!{RGx6oi=TGmiun;MV0C;FB<+;rU8B0YHlJD+@ z?jCq}M7pG_!by*%0$CW5>v(#71DB-}HlaEo=M{@J9+_H^2FoFrNhKdaVv`}k{lmjU zJdpwi9VJCy7*?Cz;q?0YCb+P?zr8#kb{mA3T45!iR1BWaq!RJ_I~$TwiN{1bTPQg- zi0}G?(KKAFw=ju#C2V*J%Suh4nL$zhVm^zQCX%jU*)+*au2_NAb}txB5D*KB;6{9r zmCL}V<8HHzW|LviYrBoQQz~TBSf(0)+g;u@>2xMvs%ofdwA$|VC8nm10g`Q)z-P%q)=070R`S*9}GtLdffQFW_5ozgaRw*K5|DV(vT(@BKVb0FY6$ z?GGk%ywT0cvLPhe7Sks~zum%YJE6b>tOS$+r|x!xaToy$ft-_w1Yqi*=QV5P^Jt#L z4uEsGT5ERtW1Q|h?g=^ZBOEPJ6pn+g`{Zz_jMPrToZFs{BS3l<_A1i2NR-(m=(g%? zIZV%C8zCI??LL|YZMtxbnZ}DUyr{+Y!=A z(iGS->b0~DWGe_re$8fQfaXa@Sl7@m^gXs~v*)9tKbC0G-KY zlva^rS!M+$NF6bjrItFgl~Hi4ZP2(nWPLbwuRrBLJ4)}fL8pNp3XmCktvJo@hy!)~ zp+nQp)1Y0Ku^g=*7{A#a;uKY!=Pbfux8a!4W!YI-?5$k!sW&wn0o%cDiOqW2Ym~Sh zbO&J%DI7PLEzyzzT>ch|Y2U5NZXv@ZS%>Z!J8+X-5b}mRn_{$Htl2oBJ%4N(_1oJd z=(KKXD8B@x{++%lJeIq(^=$14Y-_VP6Aq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8bd2d7ce96bfb1575067ba71c3aa68c0aab270e GIT binary patch literal 23149 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%0D9nRNDXw4`O3&1oC6petf9azYH2 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 ArT_o{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..36bbd354972c4c15711da8a8ea2a37bdd8bdc53a GIT binary patch literal 117914 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 d;X=Qu^%i;7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c0353bf0c26e70e0621e89f2b619f71455efc678 GIT binary patch literal 71798 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

dTuV5ZT%p4C(tx@JZ; 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4395379e0d315aa61d25cc0e1fd138e352471e2 GIT binary patch literal 48538 zcmeHP&2HQ_7~I@?>?7n=3bX-w%^^7lC~~rEI9M3Y#Rzh75CjR**;iQ*8TVUzo-8HXQy9I zf4w=s`Mbk>V50+c;9?zke0(3(9;X(?W4t`^#!6gL1M_k!?l}i>WVx0b%prNXX54cQ z#WXpG%W*kazFnW<_w`B6p_qqna(Nl@$mfZeCesTYres#scEf2Sv^CPsy=tFSQv=&S z71O8f6zWY)70*>B)xDObw!Hn5RN>xfuNbU#)3jOZ&^1KiR7}HK0q=QZty`oKI_?aX z_QcuIX3Y_I8`@{pHoUudMKOI|jBj|cI!xoyO z>RVP-e7=vcCps18)15MT)3w1&30)3_BkrImt6-p%(EdS^4_cN^h32ARr_Pr4WoVX} z^`VG&$5+3sFJdCIzzZ=`vzNx3xPr{uR$&7qQCtXeoof({Tk(uK{ zdKp}VmV?dR*hhM1`;;*%SMk;0un$O7h^m-aa*F*`D7mDP*aK#cN+QRSfPZo`;AL;O z@sh}m6J0MNu(u8{Sx`wpmA6}vd@e)X09 zezVrTcWn)G^90%x)&7EEZnf`2`#WT%Z~FUzmA(`ovR8Wbi0%FTw7TGhB;HeL{~rVg z%|7M8t(mjvjnIv2Y(Kh&&+~qm%S=-HqwlAHj`Ng%v~c#q#VPp1Pb9Djh2QTAx%;6M)zbb zR8htY#q1Pe;i_rjRqUzJ1A_OL?x!txTJ|c!B(#8gx;n-J9{Pif=K)zU-t((|`X>Mb zesvnK>%XT7V9)a7R2g7UQOo`+lq~v@OxPb&l*|kut|jd$B zg3p5u5rF!^sH0m3xC|BHDbji!uJ*$kr4-XQs9sAMF zyaU)yERrk&U#4VH{a6wRiQNnUU^f}qPMkaVOc4~DC0R8C0NAxVhlr_f$BKe40#7T? zdUhEz$n+oq+p(=n&b_}KT+S_-cda^5mg1=?_}me-BbBX+AX;_J71a!Unjl*ycSP+- z73M?s1ue3`aMevKORWN*X&1Dlru_w3=^4cX5PWTnk?1C!O98_jKK`KK#Z zuj_NaXh?-_pk&up9OI7R*%v9$3*kruEI2$%AJ7ZoNCPZ5>{?X_M;f6>AsodEMGE2E v)9g>j7VE??&A#Y(Kw=W#;sMFGC*HO(+2cjVi>$l7;kbTaqXTr{SO@+Goy&yh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..67a697512f086d4411ac0c7fc6d1e21fbe7134b4 GIT binary patch literal 31600 zcmeI5y^0)H5QRsXLqCFE#n=`obzsK=VVpPS1L=^o1? z-X;3T>l44FMc&80+He2U3uB+49lL(v^!Kl`$pO`WbAoTdW8QuXVds@rd(4%PlNG1v zj2*3dN5k`62(m4Y-ma%Kch0}_<%eH%PrFwfYO$0)g#0bo_hEZvx9}{opT5s4*cOyr z^UZjPqxlktbI-9p%j8^qon1}6`>%G{A3XRvm1WqbS6A(98TC_<0ex80r=?W&fggwj%TR z6k}($(M@6QmZBWz!)}CX?%X-Ns{n2-jB+;aW+>;*oWq+EU>3tD=i_FEa_rnWyDI^9 zG0U<}+cC<))jhi}5q>cXvrhXts>#(oyRQgIElX2OCt<4D0uhxeMpBEy6vIiDa<*VZ zr4kYqqd4Vo6sH_6F;TI^WW^{?Ih^IGrb|p#tT16M3sg^sk?Qdpl~rp}R?QOC(^;Z= zx+aCy8kJVVP|bLns-ACPVYz0d)i6{uo~CN%8(LUyU~xH&)sM%?8iSsgbsAh;&QkT$ zaj?#yN7|i-hlelv=Qt&PbjBBvaFXkkt3d91Df&Zk_RD$r$7IIyzaq)lx?5k8@s8g7 zV=_;K`KZ2cns+|-tt`LDtnZK0Q0c}LZQ?5+ne~*KeUal1lzYY%M_^M zL#{X*&s~mCs%B%*Egk71Fb8cKQwf1LXjhJO4S0h#jVXn|d~TPHbaD1ZT^dshkv;0y zu6z~wqdrfnmB=6UYgfLC{867L)k@@#`n9W2MF89vV2u$I4H6`i+I z#}uqkc%2qg2t{YE)CmlfD7;P!A%vjwR_X)>N)%qFg%C>6xodR_1w|UOQ$q|T=-kCR z#eyOc+Q=b>5;W#wnL7wNB!oOyzcZ2qFcYzgj18P^NM_Jp_@0&R?yQ zI4D!OgC4SI!2l6kG!hC$@Fa&US};+x9*x984Lqqai&ab%t;Z4>okj*~2FPjvN@}xA zN+*$lm;&-5Koi?Mmr;svAZLiY2Ef!d4`!4qebzHXMKja%uEJ)tdV+a^VJe!LW^@%e zqctLoBMej0j5Mz)@hP#FW16C{l4zotJ&8|=%^<@Vh1Em@&2MQDIJKXqpXA7zs;BuK zEdiJrxrv$dChpz--=+J)_Z_%_9XR|C(R{7B literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1062b3fc8223adbcf4a3e9015b4aadd843dd9162 GIT binary patch literal 19582 zcmeI4&q@P9492H7k3NF0B4|PIq6a+}#G9A0ES1tCl~O6iC-EWew!Mf+$iLZUcPFBU zq{;WoH#2(k@oqe>?^Alde!6{qtFA6nN~8VP%}+XfkJ3eYnN*X9{bxQ(6bFg}gLU8= zj%k*FwXE4Js8cHy+&^?y*cDqgho?3oxH|`DmTQ^=6t?7)-KnB-4;2R4qmLjYu0JuSVzFMF$Md=Zq)6U{r@M9Hk_$MP_q;6moN@j>yLOvN`|R^@XF3 zNM4J~*3xU(YNB=wjXu`nh*j5^h$im|rqM@xqJsHxp{vUa5uyB;951{dl>Csej(AL7 zMA*jCd^DbIG@wX4U;CK3L^H`AE+{iQM>Cz$&G2zVr6`>oAUwiLaoziDVj+)1pXi5NV;m$KE;T9M3epXv9tr2m3@XDxzp~mQe6}DUo|K{EbHGX&4%s4c1?tCgx#o&4AO;8J zCBULzgOb4z7?zXF4FMeD=rA|Id}Hy76-#!o#vMMy9=Ytw%+7#z#n8TSGOs*$ZRR-c zwR9f0a-Qcrt|xeBx+`4wn0TzSWzAwu;=PErN3r(f8P6Kk_I{48v-azcRfGLME@koJ IKnMrYFYQt?X8-^I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..00d9e0e357ea63ac34563a1a123209b3220196d8 GIT binary patch literal 122610 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

VF) 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61dbb98d8c48d7e1f4b0825474aac8f7a962528c GIT binary patch literal 28442 zcmeHQ&5GMF5cb}BEPaB$N}*lowTJe+u=JF+5SE3oB$$L?$RVdZMxU+E(6J=z&l>MW zD6+O%y&o0fkoD*kddJ-v(($ zne*xK>6triEb8$Sc21$01Tw#o_DV5nO`VK(mN68c9y{j(oio(+%v`mYrpNScvID#7 zv)u?ryl&aq&;W|?iFW5N&%1?&oUsL>G832y@@Evx1ZDy=K|3O)+rdt3N}p}_hhDFyTLhi!_QW)Ij594_3;C2TOt(-&uMll6i)BU z@SXKFlC6l|DP5s(D&%u-nwK6s9qEglo$dy8>T39{73FPi!L>HsU!WR7tQ9#Ynin}c z9bbCtUY%|&XQy{XHC^mQ4)2K@urHbWF;E-M7Ay~r)*q`W;<+;pPnXL$tJ|;otJ_hX z)#b+ItN9{9)BpspEz{Mzl!EWI1=O+W<>Iw2uA|3paMbB1M;B)LAh5(Bq^Nm zCefVOUL0t`=&`+kDikzn&AAMFAuRfQM#lDHZj&X}6!~&0L-hHKtTub8t)?)(5Ue=4 zrszv7winZNql_q{k;qq5L`9k+Q839rV{0Hq6H7Q4xbEgD4US5`u^fA|eum{D52k&)_!mhWGmR zJ5#Ub-jGdCb=|tBsvkZ$JiPOFnjYVMbpPq`>h{fPnhvi1?){nm{~t^@rYA?Mqgz)s zrxy|V7BnCtoS`VDIN>0@*R5!z}n z0_25~XvkdlDiC;4uM0~iP*2;H1p@L88X6Qv82)hs0=aZ}ScL#Gn4k>`al1SYia;K# z3#>v@dDw;qg%P?uZWwE*(k`$HO@{*GVJ`}6?D9A(?k=$QtZCBQAh$>&By*8oSs9YK zvU<(B!QcsN-rFmSmXKs{yxMN-~s(ZD^1+R|5jMlw@eHN&??w=rt%ZF&N8MIsx)DvU=%T za0l`>veIs1_)XKBq|&IweVbfYTngG`Ss7-{rERl+HkrV8B>ut-PDjmS hBXec-+Q!$vg(KiI)K`*~A(^YNg;UD}OkhU@rhoQTn128O literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..22c23f85ae19c8440b7558d9e7c77f452db56a4c GIT binary patch literal 49298 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 L?g)HQ2t52BwOV9y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4a1754e4b9aa49c651e26d5286a5e4ff4fd0a3e GIT binary patch literal 68300 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 F@IQ86U+e$? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e92e4584ce7f31f5f157e97fcd222e139db22548 GIT binary patch literal 45006 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..be8ced38b501385a3ec200524288214730fc378c GIT binary patch literal 6914 zcmeHLJ8l(05L{=3#6(;L2`rHkKx_$-Aq284zl_kbEDKp2gX?e^BD$)&W_osK_X2){ zcDuWNJ#U@y;r-?1zgNrh>CMM?U%pyeDZJ8 zsi_T53K7~1MuAxBd$1+UUC`1(S91g;s}P#0mpvWcw5_x&p(i1LcXM@EsisQh6^aBG z##1Eu>-1wRoh*h+_9l&#_J_#S5>AQ^8tdY|zbtgP?}se$1Z z{gEBk_B8tD;o;#1FBP;Jw{MD4w^rTcaZcqsS2MjeDvrXdKxloBq2sFR92q6IU*%;s z)Y;1%P-icn)siJ^2PDTld-876g5`TB40}{xJyZA^b58e|alAi>*<#qlZCNjLt>IeN z-<8`es?lBu!!^z$4m-U#jSy9mS_qSl5%B{WgW0{-F6roAw0KSB_VC7Xvr$lzr@g>l0;egCNzjd z6sWk2hK8-{IQSDX$?jsM>+u)Pk2oqEE3ikUaByirQXa)(K#WeOFl*XS-2s7gDNoaY zDJ69|926wQ=`PKCZ|Ee4Lc^X`f=n*uSifNpmMC;|pwv{XNqn)*d;#%J)#?hZ7AhEtjqdlV1HrRzO-y?xYg`?IYHZJ2wZno+%*+m7FWAGK z(Q<9H!Nj*XzuUpo4`5D>=Xf+RYfKpBec6~iPV_WrlGnIV*{4y^adayVBLz*>8sXHB zs-`EmGc|ZG@5tPi_GXgT5?Uh>#=e#07V3gY@&sxL;F!51>Yvk_pY8wioqzcG3p|@I Gu>1!kAbKJI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6f8b56034c86e97109e2f94e24aa63bdce463106 GIT binary patch literal 6812 zcmeHLy-EW?5Z-i_)>giXpaEM8tpj2wns9`(vlI~t;U2?>^maG%w>vjkH&+C6BW7p5 zzuByvcenF-cj4T_)&0%Wb8~s_oSXGO*FP@&&fJ-MTr`WUJ3}gR<^1$QgNc)ZnJJH3CO> z)ddhybp_7B5tD=Ny{6Sv+@JZhA@VrI_ifJf)rPT%t zSwuDnDGjNt&N3<-LX@dMNFqlKa!P|L;w%Wc73qsQG@=?2vq8Dilo=HjW?3AH*q@v* z=qLe@zsk|4H5jjufm@2&QUyGWOc80KNWN9Uh^AB;7-!mLY+(yo%L3Jw1sZ%%sr-^r ztR-z+kbb5-&9P%qWMv`(W2d_e0}BYMFf%=CnN(Fpo($<6pNLtbz<);5K8p0QV=iW; z3*f@8d+Dp|%b&iW_a_t*0@#T?76ggeW59t=JHeZsU``1L(QuEYKG4GnzA(eUjjC`5 wraJJ0ho4*vx^Si(-)s!;QznCFI-+zX#fBK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cf16080c34915a61daaa97118f9925d1d769f1ec GIT binary patch literal 7350 zcmeHLJ!=$E6rFUI{s0UAil6~o3#|h|QbYnF2wMa!wuq1-MT!*p3F2?`o^$Sb`|ix_ zW;PI;?Ay%!Iv@AV*!ku2@$uhx!|?6H*H7PnT)lrg48u|UKK>c_91U-V)03-{-@*BH zIEOEA_yVu?3#`}c>-h0Ram_LkiwG##AqSTaLJENzNE10iB(}!vKvh8uNRl*1B!%-_ zg8^yYO%(#!Fkpp&>=J4OaTz6v5(Nn2(t^`OnCM3r?AUN5CEWlPlLJRdY|jD20%Q~* zTsT6lKa5aAN`hqUO&6#YfXRf934}I&T46u3nji1XWi+CL{V>LWJ7msfxk}q&-dLq6MoKO#QOHY2immbnNa9e6EE71`jU$bD5xeb8%fV@^9W_hwTl+z`8H43)X51Z|R^Vsi(hU1k}< zSqsXiF~lry(DmV4zx}I{tnbjNSPCk!T|Ns*x+&SEkEUaFZe$fmRF6#Mv~%%Xxwtoj zC%$UqSxv7tMR&n-KRy~y<2Ih>W5syxev&Pm068%3Jmz`sY<%3`j|b!4!ll{z#o5M7 z1?D?HsT&i={n)_xwmjxvm^cA)Fvi_Gk9D3;vWW)NpPxVU=Y0F=Y0s^Tdo?N0;--3R z@0J=?haIXyT*@n=hoY)b=?ZVxKUY!Jrg&6nX}8p(@OCvj78hCN6{t2KK6dA9dOCEk za_V%pm%b;@cuhyWV0%5)%}r+XGqfpd5$SOmYwG^OlbLJ1>}ckF{`eLCiEEYs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..962a5e75d8f9bb3f39184b4775256f1038166a54 GIT binary patch literal 27142 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..81db9fb1bc16e13debc58dbac9c58f31e7b52023 GIT binary patch literal 20506 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 literal 0 HcmV?d00001 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 786cefeae9e8a4db0e058dd0b985d4ff4d6285c4..469a21ab0ed32b6f0a33c531fb82450c4ce898db 100644 GIT binary patch literal 7805 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 bQbHdr)4z2c*Z3JaXc6vU=VV*7i+uLK^Pk5- 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 f77fe6be66101ff176646808707969c22d67ff10..020c2e6b76bb9ad9cc1801b821c170389bada74b 100644 GIT binary patch literal 7910 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 literal 3725 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 literal 3509 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 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")